rvlib-shipkit 0.35.8 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.37.0
4
+
5
+ - **The leak guard grew an allowlist, because it had no way to be wrong.** Every keyword matched as a case-insensitive substring over the staged added lines, so a four letter row landed inside a real npm dependency name and inside a base64 integrity hash in `bun.lock`, and that lockfile became uncommittable forever with no per repo override of any kind. The only way past was `--no-verify`, which disarms every other gate too, so the guard was training people to switch it off. Two answers ship, in this order. A `w:<word>` keyword row now matches on word boundaries spelled in unicode rather than with `\b`, which is ascii only and would let an accented name match inside a longer accented word: that makes the PATTERN smarter rather than the guard looser, and it is opt in, so a keyword file that carries no `w:` row behaves exactly as before. Then `repo.config.ts hooks.leakAllow` (with the machine-global twin `~/.rv-private/leak-allow.json`) declares a known-good match: `paths` names the files, `patterns` names the keywords, and `inHashes` narrows to a match that falls inside a content hash, where the bytes are an address rather than prose. `paths` plus `patterns` is the shape to reach for, because it waives one keyword in named files while the same keyword still blocks everywhere else, and a single occurrence outside a hash refuses an `inHashes` entry outright.
6
+ - **A waiver is never silent.** Each entry carries a required `reason`, every waiver that fires PRINTS with that reason and the entry that owns it (on the FAIL path too, so a partial waive is visible beside what still blocks), and `hooks list` counts the declared waivers. An entry with no reason, with neither `paths` nor `patterns`, or with an unknown key dies at config load through `defineRepo`, and an allowlist file that will not parse waives nothing and WARNs, so a broken waiver leaves the guard stricter rather than looser.
7
+ - **A tracked allowlist never becomes a copy of the private keyword list.** An entry names a keyword by FINGERPRINT (`fp:` plus 12 hex of its sha256), printed beside every hit, which is how a waiver gets written without anyone typing the private word into git. `repo.config.ts` takes the fingerprint form alone and refuses a literal by name. The private machine-global file may use either. The honest limit is stated in the source: a fingerprint stops the list being read off a tracked file and is no defence against a targeted guess.
8
+ - The staged diff is now parsed per FILE (`addedLinesByFile`), so a hit says which file it came from and a waiver can name one. Which file a line belongs to is decided by COUNTING each hunk body from the size its header declares, never by trusting a `+++ …` line wherever it appears: an added line whose own text starts with `++` reaches the parser looking exactly like a file header, and believing one inside a hunk would let ordinary looking text drop every added line after it (`++ /dev/null`) or file the rest under a name a waiver covers (`++ b/bun.lock`). Substring rows match through a case-insensitive regex instead of `indexOf` over a lowercased copy, because one unicode character lowercases into two and slides every offset after it, and those offsets are what decide whether a match sits inside a content hash. A content-hash span is consulted ONLY by an entry that asked for `inHashes`: an api token is also a long base64 run, so skipping those spans by default would silence the guard on the exact shape it exists to catch.
9
+
10
+ ## 0.36.0
11
+
12
+ - **`shipkit smoke` — verify the way the end user does, after a deploy.** A read only Playwright pass: it logs in through the app's OWN login page, visits every route `repo.config.ts smoke` declares, samples the body text until it stops growing (a `waitFor` selector first, because a spinner holds the length constant), counts the selector's matches, measures whether the PAGE scrolls sideways, screenshots each route under `tmp/smoke/`, and collects console errors, page errors, failed requests and 4xx/5xx responses. The exit code is the number of failed routes. The password is a secret NAME, read through the repo's new `credsReadCommand` template (the READ twin of `credsCommand`, `{name}` and `{field}` substituted, no shell), never argv, env or config, and `defineRepo` refuses a value typed where the name belongs and a login declared without the reader. Playwright is resolved from the CONSUMER repo at run time, never carried by this package: a repo without it gets one line naming `bun add -d playwright && npx playwright install chromium`. When the host sends `x-pockethost-ratelimit-ip-hourly-*` headers, the report prints the budget and labels a CORS-shaped failure as the backend refusing rather than the app failing, telling the hourly quota from the concurrent cap by the remaining count. Between routes it pauses `paceMs` (default 3000, measured against a 15-concurrent cap: 0 reported 8 of 12 routes, 3000 reports 12 of 12). A route with `open` (a link selector) follows the first match's href after it and judges the landed page as its own row, which is how a detail page with a real id gets visited without the config knowing one. The login form lives at `login.path`, default `/login`. A problem collected during the login is a failed row of its own, and the pause runs before the row label moves, so a previous page's late failure lands on the page that caused it. `emailSecret` is refused when the reader template carries no `{field}`: the account read would answer the value, and the run would type the password into the identity box, where a failed-login journal keeps it.
13
+
14
+ - **The bootstrap wires `origin` as `git@`, never `https://`.** The remote came from `gh config get git_protocol`, which defaults to https, so a started repo got an https origin that re-asks for a credential on every push and says nothing about it until the first push fails. That call is gone, and the https url left the `repo view --json` field list with it: the remote is the `sshUrl` gh returned, and an answer carrying none is a WARN rather than a url the module builds itself, because it knows no host and a guessed `github.com` is the same silent failure one host over. The test account still answers `git_protocol` https and still hands back an https url beside the ssh one, so the two tests pinning this run against a hostile environment rather than an absent one.
15
+
16
+ - **`shipkit fleet` — the repos on this disk that install the toolkit, and how stale each one is.** Measured on a real machine the day it landed: 10 of 14 repos behind, `rvlib-shipkit` spread across 0.21.0, 0.24.0, 0.26.0, 0.30.0, 0.35.5 and 0.35.8, and one repo two majors behind on `rvlib-pb-mobx`. A doctrine page that documents the current binary is loaded into repos running six different ones, so the drift was invisible from any single repo. It SCANS rather than reading the reporting registry, because that registry only carries repos that already report, and a repo that never reported is the one most likely to be stale. `--update` never writes: it runs each repo's own `check --fix SK005`, which owns the update with the lease and the guards it already has. It judges by SK005's own line rather than by the exit code, because that code covers every rule in that repo. A changed MAJOR is refused without `--major`, and that is deliberately not the semver rule: on a 0.x package every minor is breaking, which fired on 10 of 14 repos including the jumps SK005 performs by itself daily, so the flag was rewritten to fire only on the case a human has to own.
17
+
18
+ - **`shipkit prose` + rule SK014 — the STE standard, enforceable.** ASD-STE100 was house doctrine with no tooling: three regex classes lived inside one repo's hand-rolled lint, and nothing shipped to any consumer. The checker now lives here, in `prose/ste.ts`, with the file walking and the wire lines in `prose/run-prose.ts`. Three tiers, and the split is the whole design. `fix` classes are mechanical (contractions, latin, one-to-one substitutions that carry their verb inflection across, empty words) and `--fix` rewrites them idempotently. `hard` classes are real violations whose repair is a judgement call (banned modal, semicolon, `-ing` after a comma, perfect tense, a vague word carrying no measurable fact), so they FAIL and name what STE wants instead: `should` becomes `must` or `can` depending on what the writer meant, and a machine that guesses writes a false requirement. `soft` classes (sentence length, passive voice, paragraph length) need a human read, so they WARN, and an optional `baseline` turns them into a ratchet that only goes down. Scope is DECLARED, never assumed: a repo with no `prose.include` is skipped with the line that opts it in.
19
+ - **The Untouchables are masked before any rule runs**, and the mask preserves every offset: frontmatter, code fences, inline code, double-quoted verbatim spans, URLs and link targets, blockquotes. A checker that rewrites a quoted requirement destroys the thing the quote was protecting, and a reported line number that is off by a fence is a line number nobody trusts. A code span counts as ONE word toward a sentence limit, and an em-dash separator is structure rather than sentence punctuation, so a route line is not read as one forty-word sentence.
20
+ - The approved-word dictionary is deliberately NOT in this package. It is copyrighted, so it cannot ride in a tarball, and it bans most of a toolkit's own vocabulary: `check` is a noun only, `run`, `render`, `delete`, `verify` and `confirm` are all not-approved verbs. Pragmatic mode, the structural rules plus a slop table, is what ships.
21
+
22
+ - **`shipkit units` — the inventory that no command owned.** Every task, rule and discovered check on one table, with what each declared about itself: whether `shipkit ci` runs it or only `shipkit check`, whether a failure blocks or warns, its cache population and TTL, what it reads, and who owns it. `shipkit rules` covers a third of the rows and `shipkit keys` answers a different question (what a unit is keyed on right now), so the one thing an operator most needs, can this stop me, had no surface at all and was answered by opening source files. `--proactive` narrows to the units the gate never runs, `--blocking` to the ones that can stop you, `--json` is the machine shape, `--out <file>` writes the markdown table.
23
+ - **`STATUS.full.md`, refreshed beside STATUS.md by both `ci` and `check`.** Generated, gitignored and untracked through the same SK004 policy as STATUS.md. It deliberately carries NO run state: no timestamp, no key hashes, no verdict, so the file moves only when a declaration moves and its diff is the audit trail of that change. STATUS.md links to it.
24
+ - **A measured NEGATIVE no longer caches for the unit's full TTL.** `check-suite.ts` decided cacheability with `(clean || !r.blocking)`, so any non-blocking check remembered a red for its whole window. With long TTLs that is a red outliving the fix that cleared it, and a discovered check's world key carries no rotation material, so re-minting the credential does not bust it either. A clean answer still keeps for the declared TTL, because "the world is fine" is what a long TTL is for. A negative keeps for one hour at most. `Stored<T>` grew an optional `keeps`, so an entry carries its own window instead of the reader inferring one from the unit, and old entries without it fall back to the declared TTL.
25
+ - `gateInventory()` in `ledger/gate-units.ts` is now the single walk of the three registries, and `gateUnits()` is that list narrowed to the cache declaration. A surface can no longer enumerate a different unit set than the one the key layer keyed. `StatusInput` grew an optional `sidecar`, so a caller that writes no companion file never advertises one.
26
+
3
27
  ## 0.35.8
4
28
 
5
29
  - **`release.requireHealthy` had become a no-op for exactly the checks it exists for.** `runCi` hardcoded `gateOnly: true` on its checks call, and `requireHealthy` is implemented one layer up as `onlyBlockingChecks: false` plus `failOnAdvisory: true`. The gate filter runs before severity is considered, so a repo that asked for a missing publish credential to stop its release had that request silently dropped the moment its cred check declared `gate = false`. Reproduced through `runCi` with the exact option shape the release path builds, against a check exiting 1 with a FAIL line: the line never appeared and the gate returned 0. `gateOnly` now yields to `failOnAdvisory`, so `requireHealthy` re-arms every opted-out check. Two tests, one per option shape.
package/README.md CHANGED
@@ -49,6 +49,12 @@ shipkit assets serve|fill|add|gen|identity|check — the asset atelier
49
49
  shipkit pb collection <name> — scaffold a PocketBase collection's UI
50
50
  folder (columns/card/grid/form/index) from the app's
51
51
  generated pb types, create-only [--dry-run]
52
+ shipkit smoke browser pass over a DEPLOYED web app, read only: login
53
+ through the app's own form, every route in repo.config
54
+ `smoke`, screenshots, console/network problems, the host
55
+ quota headers, a detail page per `open` link. Exit code =
56
+ failed routes. Needs playwright in the repo
57
+ [--base <url> --routes /a,/b]
52
58
  shipkit serve the repo's ONE server: hub page, graphs, studio, status
53
59
  shipkit reflect your prompts across every Claude Code session of a repo:
54
60
  all config roots, queued-mid-run ones included, harness
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "0.35.8",
3
+ "version": "0.37.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",
@@ -16,7 +16,7 @@ export class BumpCommand extends Command {
16
16
  ['a breaking release', 'shipkit bump major'],
17
17
  ['one sub-unit (ids: shipkit releases)', 'shipkit bump minor rvlib-shipkit'],
18
18
  ['a whole mixed batch in ONE gate, each unit at the kind its heading declares', 'shipkit bump --all'],
19
- ['every unit that has Unreleased notes, all at the same kind', 'shipkit bump patch --all'],
19
+ ['every unit carrying Unreleased notes, all at the same kind', 'shipkit bump patch --all'],
20
20
  ['one unit, kind from its own heading', 'shipkit bump rvlib-shipkit'],
21
21
  ['rehearse the whole thing', 'shipkit bump minor --dry-run'],
22
22
  ['bump and commit, release later', 'shipkit bump patch --no-release'],
@@ -0,0 +1,116 @@
1
+ // shipkit fleet — every repo on this disk that installs the toolkit, and how
2
+ // far behind each one is. Reports by default. `--update` hands the work to
3
+ // each repo's OWN shipkit, so rule SK005 performs the update with the lease,
4
+ // the dirty-tree guard and the frozen guard it already owns.
5
+ import { Command, Option } from 'clipanion'
6
+ import { existsSync } from 'fs'
7
+ import { join } from 'path'
8
+ import { npmLatest, resolveLatest, scanFleet, type FleetRepo } from '../fleet/scan-fleet.ts'
9
+ import { spawnWithTimeout } from '../core/spawn-timeout.ts'
10
+
11
+ const short = (name: string): string => name.replace(/^rvlib-/, '')
12
+
13
+ export class FleetCommand extends Command {
14
+ static override paths = [['fleet']]
15
+
16
+ static override usage = Command.Usage({
17
+ category: 'Health',
18
+ description: 'every repo on this disk that installs the toolkit, and how far behind each one is',
19
+ examples: [
20
+ ['what is stale out there', 'shipkit fleet'],
21
+ ['filesystem only, no registry', 'shipkit fleet --offline'],
22
+ ['bring every behind repo up, through its own gate', 'shipkit fleet --update'],
23
+ ['one repo only', 'shipkit fleet --update --only my-app'],
24
+ ['machine-readable', 'shipkit fleet --json'],
25
+ ],
26
+ })
27
+
28
+ root = Option.String('--root', { required: false, description: 'directory holding the repos (default: the parent of this repo)' })
29
+ update = Option.Boolean('--update', false, { description: "run each behind repo's own `shipkit check`, which is where rule SK005 performs the guarded update" })
30
+ only = Option.Array('--only', { description: 'limit to these repo directory names (repeatable)' })
31
+ offline = Option.Boolean('--offline', false, { description: 'skip the registry: report what is installed, never what is current' })
32
+ major = Option.Boolean('--major', false, { description: 'let --update cross a breaking boundary too. Default off: a major (or a 0.x minor) is a porting job, not a bump' })
33
+ json = Option.Boolean('--json', false, { description: 'machine-readable FleetRepo[]' })
34
+
35
+ async execute(): Promise<number> {
36
+ const root = this.root ?? join(process.cwd(), '..')
37
+ let repos = scanFleet(root)
38
+ if (this.only != null && this.only.length > 0) repos = repos.filter((r) => this.only!.includes(r.name))
39
+ const w = (l: string) => this.context.stdout.write(`${l}\n`)
40
+ if (repos.length === 0) {
41
+ w(`skip: no repo under ${root} depends on an rvlib-* package`)
42
+ return 0
43
+ }
44
+ if (!this.offline) await resolveLatest(repos, npmLatest)
45
+
46
+ if (this.json) {
47
+ this.context.stdout.write(`${JSON.stringify(repos, null, 2)}\n`)
48
+ return 0
49
+ }
50
+
51
+ for (const r of repos) w(this.row(r))
52
+ const behind = repos.filter((r) => r.behind.length > 0 && !r.linked)
53
+ if (this.offline) {
54
+ w(`ok: ${repos.length} repo(s) scanned, registry not consulted (--offline)`)
55
+ return 0
56
+ }
57
+ if (behind.length === 0) {
58
+ w(`ok: ${repos.length} repo(s), every published dep current`)
59
+ return 0
60
+ }
61
+ if (!this.update) {
62
+ w(`WARN ${behind.length}/${repos.length} repo(s) behind — rerun with --update, which runs each repo's own gate so SK005 does the write`)
63
+ return 0
64
+ }
65
+ return await this.runUpdates(behind, w)
66
+ }
67
+
68
+ private row(r: FleetRepo): string {
69
+ const cells = r.deps.map((d) => {
70
+ if (d.pin.startsWith('link:') || d.pin.startsWith('file:')) return `${short(d.name)}=source`
71
+ const at = d.installed ?? 'absent'
72
+ const stale = r.behind.some((b) => b.name === d.name)
73
+ const breaks = r.breaking.some((b) => b.name === d.name)
74
+ return stale ? `${short(d.name)} ${at} → ${d.latest}${breaks ? ' BREAKING' : ''}` : `${short(d.name)} ${at}`
75
+ })
76
+ const flag = r.linked ? 'source' : r.breaking.length > 0 ? `${r.breaking.length} BREAKING` : r.behind.length > 0 ? `${r.behind.length} behind` : 'current'
77
+ return ` ${r.name.padEnd(24)} ${flag.padEnd(10)} ${cells.join(', ')}`
78
+ }
79
+
80
+ /** the update is NOT performed here. Each repo runs its own binary, which
81
+ * is the only copy whose SK005 matches the rules that repo is pinned to,
82
+ * and the only one holding that repo's mutate lease. */
83
+ private async runUpdates(behind: FleetRepo[], w: (l: string) => void): Promise<number> {
84
+ let failed = 0
85
+ for (const r of behind) {
86
+ if (r.breaking.length > 0 && !this.major) {
87
+ w(`WARN ${r.name}: ${r.breaking.map((d) => `${short(d.name)} ${d.installed} → ${d.latest}`).join(', ')} crosses a breaking boundary — that is a porting job. Rerun with --major once somebody owns it`)
88
+ failed++
89
+ continue
90
+ }
91
+ const cli = join(r.dir, 'node_modules', 'rvlib-shipkit', 'src', 'cli.ts')
92
+ if (!existsSync(cli)) {
93
+ w(`WARN ${r.name}: no shipkit installed, so it cannot update itself — run: cd ${r.dir} && bun update --latest ${r.behind.map((d) => d.name).join(' ')}`)
94
+ failed++
95
+ continue
96
+ }
97
+ // `check --fix SK005` is the narrow form: a bare rule id is refused,
98
+ // and a full `check` pays for every world probe to reach one rule
99
+ const res = await spawnWithTimeout(['bun', cli, 'check', '--fix', 'SK005'], { cwd: r.dir, timeoutMs: 180_000 })
100
+ // judge by what SK005 SAID, never by the exit code: that code covers
101
+ // every rule in that repo, and a repo can be red for a reason this
102
+ // command neither caused nor owns
103
+ const line = res.out.split('\n').find((l) => /\brvlib-/.test(l) && (l.startsWith('ok:') || l.startsWith('WARN') || l.startsWith('FAIL')))
104
+ if (line?.startsWith('ok:') === true) w(`ok: ${r.name} — ${line.slice(4)}`)
105
+ else {
106
+ failed++
107
+ w(`WARN ${r.name} did not move — ${line ?? `check exited ${res.code} with no rvlib line`}`)
108
+ w(` fix: cd ${r.dir} && bun update --latest ${r.behind.map((d) => d.name).join(' ')}`)
109
+ }
110
+ }
111
+ // a repo whose gate refused (dirty tree, frozen, a lease held elsewhere)
112
+ // is a REPORT, not a failure of this command: SK005 defers on purpose
113
+ w(failed === 0 ? `ok: ${behind.length} repo(s) updated through their own gate` : `WARN ${failed}/${behind.length} repo(s) did not update — each line above says why`)
114
+ return 0
115
+ }
116
+ }
@@ -0,0 +1,65 @@
1
+ // shipkit prose — the STE checker as a command, so a writer can run it on one
2
+ // file while drafting instead of waiting for the gate. Rule SK014 runs the
3
+ // same module over the repo's declared scope.
4
+ import { Command, Option } from 'clipanion'
5
+ import { loadRepoConfig } from '../repo-config.ts'
6
+ import { classSummary, proseLines, runProseCheck } from '../prose/run-prose.ts'
7
+
8
+ export class ProseCommand extends Command {
9
+ static override paths = [['prose']]
10
+
11
+ static override usage = Command.Usage({
12
+ category: 'Health',
13
+ description: 'STE prose check (identity/writing-voice.md): mechanical fixes, hard violations, soft warnings',
14
+ examples: [
15
+ ['every file this repo declares as prose', 'shipkit prose'],
16
+ ['one file while drafting', 'shipkit prose wiki/build/shipkit.md'],
17
+ ['apply every mechanical rewrite', 'shipkit prose --fix'],
18
+ ['the procedural 20-word limit', 'shipkit prose --strict'],
19
+ ['machine-readable', 'shipkit prose --json'],
20
+ ],
21
+ })
22
+
23
+ // `targets`, not `paths`: clipanion's Command owns the static `paths`
24
+ targets = Option.Rest()
25
+ fix = Option.Boolean('--fix', false, { description: 'apply the mechanical rewrites (contractions, latin, one-to-one substitutions, empty words)' })
26
+ json = Option.Boolean('--json', false, { description: 'machine-readable { files, byClass, hits }' })
27
+ strict = Option.Boolean('--strict', false, { description: 'the 20-word procedural sentence limit instead of the 25-word descriptive one' })
28
+ cap = Option.String('--cap', '25', { description: 'how many hit lines to print before the per-class totals' })
29
+
30
+ async execute(): Promise<number> {
31
+ const root = process.cwd()
32
+ const config = await loadRepoConfig(root)
33
+ const cfg = { ...(config?.prose ?? {}) }
34
+ if (this.strict) cfg.strict = true
35
+ const explicit = this.targets.length > 0
36
+ if ((cfg.include ?? []).length === 0 && !explicit) {
37
+ this.context.stdout.write(
38
+ `skip: this repo declares no prose scope — add \`prose: { include: ['**/*.md'], exempt: [...] }\` to repo.config.ts (identity/writing-voice.md)\n`,
39
+ )
40
+ return 0
41
+ }
42
+ const r = runProseCheck({ root, config: cfg, paths: explicit ? this.targets : undefined, fix: this.fix })
43
+ if (this.json) {
44
+ // every hit carries its file: a flat list without one cannot be
45
+ // acted on, and the grouping is already in byFile
46
+ const hits = r.byFile.flatMap((f) => f.hits.map((h) => ({ file: f.file, ...h })))
47
+ this.context.stdout.write(`${JSON.stringify({ files: r.files, byTier: r.byTier, byClass: r.byClass, fixed: r.fixed, hits }, null, 2)}\n`)
48
+ return r.byTier.fix + r.byTier.hard > 0 ? 1 : 0
49
+ }
50
+ const w = (l: string) => this.context.stdout.write(`${l}\n`)
51
+ if (r.fixed.length > 0) w(`ok: rewrote ${r.fixed.length} file(s)`)
52
+ for (const l of proseLines(r, { cap: Number(this.cap) || 25 })) w(l)
53
+ const blocking = r.byTier.fix + r.byTier.hard
54
+ const baseline = cfg.baseline
55
+ const overBaseline = baseline != null && r.byTier.soft > baseline
56
+ if (blocking === 0 && !overBaseline) {
57
+ w(`ok: ${r.files} file(s) STE-clean${r.byTier.soft > 0 ? `, ${r.byTier.soft} soft warning(s)${baseline != null ? ` (baseline ${baseline})` : ''}` : ''}`)
58
+ return 0
59
+ }
60
+ if (blocking > 0)
61
+ w(`FAIL: ${blocking} STE violation(s) in ${r.byFile.length} file(s) — ${classSummary(r)}${r.fixable > 0 ? `\n fix: shipkit prose --fix clears ${r.fixable} of them` : ''}`)
62
+ if (overBaseline) w(`FAIL: ${r.byTier.soft} soft hits, above this repo's baseline of ${baseline} — lower the count, or move the baseline down in repo.config.ts`)
63
+ return 1
64
+ }
65
+ }
@@ -0,0 +1,42 @@
1
+ // shipkit smoke — verify the way the end user does: after a deploy, log in
2
+ // through the real login page, walk every route, read only. The routes and
3
+ // the login come from repo.config.ts `smoke`, the password from the keychain.
4
+ import { Command, Option } from 'clipanion'
5
+ import { loadRepoConfig } from '../repo-config.ts'
6
+ import { DEFAULT_PACE_MS, exitCodeFor, renderReport, runSmoke } from '../smoke/run-smoke.ts'
7
+
8
+ export class SmokeCommand extends Command {
9
+ static override paths = [['smoke']]
10
+
11
+ static override usage = Command.Usage({
12
+ category: 'Health',
13
+ description: 'browser smoke pass over a deployed web app: login through the app, visit every declared route, read only, screenshots + console/network problems + host quota. Exit code = failed routes',
14
+ examples: [
15
+ ['every route in repo.config smoke, against its base url', 'shipkit smoke'],
16
+ ['a preview deploy', 'shipkit smoke --base https://abc123.my-app.pages.dev'],
17
+ ['two routes only', 'shipkit smoke --routes /orders,/clients'],
18
+ ],
19
+ })
20
+
21
+ base = Option.String('--base', { description: 'the url to visit instead of smoke.base (a preview deploy, a local dev server)' })
22
+ routes = Option.String('--routes', { description: 'comma-separated paths to visit instead of the whole list' })
23
+
24
+ async execute(): Promise<number> {
25
+ const root = process.cwd()
26
+ const config = await loadRepoConfig(root)
27
+ const w = (l: string) => this.context.stdout.write(`${l}\n`)
28
+ if (config.smoke == null) {
29
+ w(`FAIL this repo declares no smoke config — add \`smoke: { base, routes, login? }\` to repo.config.ts (build/shipkit.md)`)
30
+ return 1
31
+ }
32
+ try {
33
+ const rep = await runSmoke(root, config.smoke, { base: this.base, routes: this.routes, credsReadCommand: config.credsReadCommand }, { print: w })
34
+ for (const l of renderReport(rep, { paceMs: config.smoke.paceMs ?? DEFAULT_PACE_MS })) w(l)
35
+ return exitCodeFor(rep)
36
+ } catch (e) {
37
+ const msg = e instanceof Error ? e.message : String(e)
38
+ this.context.stderr.write(`${msg.startsWith('FAIL') ? msg : `FAIL ${msg}`}\n`)
39
+ return 1
40
+ }
41
+ }
42
+ }
@@ -21,7 +21,7 @@ export class StartCommand extends Command {
21
21
 
22
22
  name = Option.String()
23
23
  root = Option.String('--root', { description: 'parent dir for repos (default ~/dev)' })
24
- include = Option.Array('--include', { description: 'line CLAUDE.md must carry, repeatable, order kept. Omitted means only ensure the dir' })
24
+ include = Option.Array('--include', { description: 'line CLAUDE.md must carry, repeatable, order kept. Omitted means the run only creates the dir' })
25
25
  alsoOk = Option.Array('--also-ok', { description: 'alternative lines satisfying the first --include — never double-add' })
26
26
  skip = Option.Array('--skip', { description: 'repo names shipkit never touches: no CLAUDE.md include, no wiring' })
27
27
  github = Option.Boolean('--github', { description: 'ON by default when a repo is being STARTED (a dir this run created, or one with a package.json): it gets a PRIVATE github repo wired as origin, and the first commit is pushed to it. Pass --github to force it for a pre-existing folder, --no-github to keep everything local, the first commit included (SHIPKIT_NO_GITHUB=1 does it for every run)' })
@@ -0,0 +1,82 @@
1
+ // shipkit units — THE inventory: every task, rule and discovered check on one
2
+ // table, with what it declared about itself. `shipkit rules` covers a third of
3
+ // it, `shipkit keys` answers a different question (what is a unit keyed on,
4
+ // right now), and neither says whether a unit blocks you or only reports the
5
+ // world. That answer had no surface, so it was reconstructed by reading source
6
+ // files, which is how a repo ends up with checks nobody can account for.
7
+ import { writeFileSync } from 'fs'
8
+ import { resolve } from 'path'
9
+ import { Command, Option } from 'clipanion'
10
+ import { loadRepoConfig } from '../repo-config.ts'
11
+ import { envTier, gateInventory, tieredTasks } from '../ledger/gate-units.ts'
12
+ import { renderUnitTable } from '../ledger/unit-table.ts'
13
+ import { classify } from '../ledger/git-key.ts'
14
+
15
+ export class UnitsCommand extends Command {
16
+ static override paths = [['units']]
17
+
18
+ static override usage = Command.Usage({
19
+ category: 'Health',
20
+ description: 'every gate unit on one table: task, rule or check, whether it blocks or only warns, whether the gate runs it or only `shipkit check`, and how it caches. Reports, never gates',
21
+ examples: [
22
+ ['the whole inventory', 'shipkit units'],
23
+ ['the big markdown table, written to a file', 'shipkit units --out STATUS.full.md'],
24
+ ['only the ones the gate never runs', 'shipkit units --proactive'],
25
+ ['machine-readable', 'shipkit units --json'],
26
+ ],
27
+ })
28
+
29
+ json = Option.Boolean('--json', false, { description: 'machine-readable UnitRow[] with the resolved cache population' })
30
+ md = Option.Boolean('--md', false, { description: 'the full markdown table on stdout, the same bytes --out writes' })
31
+ out = Option.String('--out', { required: false, description: 'write the markdown table to this file instead of stdout' })
32
+ blocking = Option.Boolean('--blocking', false, { description: 'only units whose failure can stop you' })
33
+ proactive = Option.Boolean('--proactive', false, { description: 'only units the gate never runs (`shipkit check` reports them)' })
34
+ full = Option.Boolean('--full', false, { description: 'resolve tasks the way a FULL-tier run resolves them' })
35
+
36
+ async execute(): Promise<number> {
37
+ const root = process.cwd()
38
+ const c = await loadRepoConfig(root)
39
+ const tier = this.full ? 'full' : envTier()
40
+ let rows = gateInventory(root, c, tieredTasks(c, root, tier))
41
+ if (this.blocking) rows = rows.filter((r) => r.blocking !== false)
42
+ if (this.proactive) rows = rows.filter((r) => !r.gate)
43
+
44
+ if (this.json) {
45
+ this.context.stdout.write(`${JSON.stringify(rows.map((r) => ({ ...r, population: classify(r.decl).population })))}\n`)
46
+ return 0
47
+ }
48
+
49
+ if (this.md || this.out != null) {
50
+ const md = renderUnitTable({ name: c.name, rows })
51
+ if (this.out != null) {
52
+ const target = resolve(root, this.out)
53
+ writeFileSync(target, `${md.trimEnd()}\n`)
54
+ this.context.stdout.write(`ok: ${rows.length} unit(s) written to ${this.out}\n`)
55
+ return 0
56
+ }
57
+ this.context.stdout.write(`${md}\n`)
58
+ return 0
59
+ }
60
+
61
+ // a catalog, not a health run — no wire prefixes, or anything parsing
62
+ // wire output reads this as a passing check
63
+ this.context.stdout.write(`${'kind'.padEnd(5)} ${'unit'.padEnd(28)} ${'runs in'.padEnd(9)} ${'failure'.padEnd(10)} ${'cache'.padEnd(11)} ttl\n`)
64
+ for (const r of rows) {
65
+ const cls = classify(r.decl)
66
+ const cache = cls.error != null ? 'BROKEN' : cls.population === 'none' ? (cls.decided === true ? 'never' : 'UNDECLARED') : cls.population
67
+ const blocking = r.blocking == null ? 'UNDECLARED' : r.blocking ? 'blocks' : 'warns'
68
+ const runsIn = r.optIn ? 'opt-in' : r.gate ? 'gate' : 'proactive'
69
+ this.context.stdout.write(
70
+ `${r.decl.kind.padEnd(5)} ${r.decl.id.padEnd(28)} ${runsIn.padEnd(9)} ${blocking.padEnd(10)} ${cache.padEnd(11)} ${r.decl.ttl ?? ''}\n`,
71
+ )
72
+ }
73
+ const undeclared = rows.filter((r) => { const cl = classify(r.decl); return cl.population === 'none' && cl.decided !== true }).length
74
+ const proactive = rows.filter((r) => !r.gate && !r.optIn).length
75
+ this.context.stdout.write(
76
+ `\n${rows.length} units — ${rows.length - proactive} in the gate, ${proactive} proactive · ${undeclared} still uncacheable with nobody having looked\n`,
77
+ )
78
+ if (undeclared > 0) this.context.stdout.write(` why each one: shipkit keys --burn-down\n`)
79
+ this.context.stdout.write(` the full table, refreshed by every gate run: STATUS.full.md\n`)
80
+ return 0
81
+ }
82
+ }
package/src/cli.ts CHANGED
@@ -11,17 +11,21 @@ import { CheckCommand } from './cli/cmd_Check.ts'
11
11
  import { CommandsCommand } from './cli/cmd_Commands.ts'
12
12
  import { CiCommand } from './cli/cmd_Ci.ts'
13
13
  import { ContractsCommand } from './cli/cmd_Contracts.ts'
14
+ import { FleetCommand } from './cli/cmd_Fleet.ts'
14
15
  import { HooksCommitMsgCommand, HooksInstallCommand, HooksListCommand, HooksPreCommitCommand } from './cli/cmd_Hooks.ts'
15
16
  import { InitCommand } from './cli/cmd_Init.ts'
16
17
  import { KeysCommand } from './cli/cmd_Keys.ts'
17
18
  import { MvCommand } from './cli/cmd_Mv.ts'
18
19
  import { NeedsCommand } from './cli/cmd_Needs.ts'
19
20
  import { PbCollectionCommand } from './cli/cmd_Pb.ts'
21
+ import { ProseCommand } from './cli/cmd_Prose.ts'
20
22
  import { ReflectCommand } from './cli/cmd_Reflect.ts'
21
23
  import { ReleaseCommand } from './cli/cmd_Release.ts'
22
24
  import { ReleasesCommand } from './cli/cmd_Releases.ts'
23
25
  import { RulesCommand } from './cli/cmd_Rules.ts'
26
+ import { UnitsCommand } from './cli/cmd_Units.ts'
24
27
  import { ServeCommand } from './cli/cmd_Serve.ts'
28
+ import { SmokeCommand } from './cli/cmd_Smoke.ts'
25
29
  import { StartCommand } from './cli/cmd_Start.ts'
26
30
  import { TuiCommand } from './cli/cmd_Tui.ts'
27
31
  import { WikiLintCommand, WikiServeCommand } from './cli/cmd_Wiki.ts'
@@ -68,10 +72,14 @@ export function buildCli(): Cli {
68
72
  cli.register(InitCommand)
69
73
  cli.register(StartCommand)
70
74
  cli.register(NeedsCommand)
75
+ cli.register(FleetCommand)
71
76
  cli.register(PbCollectionCommand)
77
+ cli.register(ProseCommand)
78
+ cli.register(SmokeCommand)
72
79
  cli.register(ReflectCommand)
73
80
  cli.register(RulesCommand)
74
81
  cli.register(KeysCommand)
82
+ cli.register(UnitsCommand)
75
83
  cli.register(CommandsCommand)
76
84
 
77
85
  return cli
@@ -24,6 +24,12 @@ type Logic = (t: CheckCtx) => unknown
24
24
  type Prep = { skip?: string; cacheExtra?: string }
25
25
  type Runner = { name: string; ttlMs: number; version: number; prep?: () => Prep; logic: Logic; blocking: boolean }
26
26
 
27
+ /** the longest a MEASURED NEGATIVE is remembered, whatever TTL the unit
28
+ * declared. Long enough to stop a re-ask storm inside one working session,
29
+ * short enough that the world being fixed is noticed the same day. A clean
30
+ * answer is not capped: "fine" is what a long TTL is for. */
31
+ const NEGATIVE_TTL_CAP_MS = 60 * 60 * 1000
32
+
27
33
  export type GetSecret = (name: string, field: 'get' | 'account') => string | null
28
34
 
29
35
  export type SecretSpec = {
@@ -158,8 +164,12 @@ export class CheckSuite {
158
164
  const key = this.cacheKey(r, prep.cacheExtra ?? '')
159
165
  const path = entryPath(this.storeDir, this.name, r.name, key)
160
166
  const entry = readStored<Line[]>(path, key)
167
+ // an entry may keep for LESS than the declared TTL: a measured negative
168
+ // writes its own shorter window (below), so the run after a fix sees
169
+ // the fix rather than the memory of the breakage
170
+ const keeps = entry?.keeps ?? r.ttlMs
161
171
  const fresh = r.ttlMs > 0 && process.env[this.freshEnv] == null
162
- && entry != null && this.now() - entry.at < r.ttlMs && this.now() >= entry.at
172
+ && entry != null && this.now() - entry.at < keeps && this.now() >= entry.at
163
173
  if (fresh) {
164
174
  // the stamp was baked in when the check RAN (below): a replay
165
175
  // prints the stored lines VERBATIM, so the live run and every
@@ -191,10 +201,19 @@ export class CheckSuite {
191
201
  // measured negative a probe that RAN and came back bad ("the box is
192
202
  // off", "the token answered 403", "five repos are
193
203
  // uncatalogued") is a real answer about the world.
194
- // Replaying it for its TTL is CORRECT: the world
204
+ // Replaying it is CORRECT for a WHILE: the world
195
205
  // does not fix itself between two gate runs, and
196
206
  // re-asking every 30 seconds spends a third-party
197
- // API call to be told the same thing.
207
+ // API call to be told the same thing. It is NOT
208
+ // correct for the full TTL, and that asymmetry is
209
+ // deliberate: a clean pass says the world is fine,
210
+ // which a long TTL is exactly right for, while a
211
+ // negative names something a HUMAN is expected to
212
+ // go fix, and the fix is invisible from here. A
213
+ // 100d TTL over a revoked token would show the
214
+ // same red for 100 days after it was re-minted,
215
+ // because the token value is not in this key. So a
216
+ // negative keeps for NEGATIVE_TTL_CAP at most.
198
217
  // unmeasured a probe that could not run at all ("registry
199
218
  // unreachable", "no cred", "rate limited", tool
200
219
  // missing) produced no answer. Caching it asserts
@@ -208,14 +227,18 @@ export class CheckSuite {
208
227
  const unmeasured = t.unmeasured || t.lines.some((l) => l.kind === 'skip')
209
228
  const clean = !t.failed && !t.warned
210
229
  const cacheable = r.ttlMs > 0 && !unmeasured && (clean || !r.blocking)
230
+ // a clean answer keeps for the whole declared TTL. a negative keeps
231
+ // only long enough to stop the re-ask storm, so the run after a human
232
+ // fixes the world sees the fix instead of the memory of the breakage
233
+ const keepsFor = clean ? r.ttlMs : Math.min(r.ttlMs, NEGATIVE_TTL_CAP_MS)
211
234
  const lines = cacheable
212
- ? t.lines.map((l, i): Line => (i === 0 ? { ...l, text: `${l.text} (${clean ? 'cached' : 'measured'} at ${fmtStamp(this.now())} until ${fmtStamp(this.now() + r.ttlMs)})` } : l))
235
+ ? t.lines.map((l, i): Line => (i === 0 ? { ...l, text: `${l.text} (${clean ? 'cached' : 'measured'} at ${fmtStamp(this.now())} until ${fmtStamp(this.now() + keepsFor)})` } : l))
213
236
  : t.lines
214
237
  this.onResult?.(r.name, { lines, status: t.failed ? 'fail' : t.warned ? 'warn' : 'pass', cached: false, ms: performance.now() - started })
215
238
  // this check writes its OWN file, and only its own: nothing here reads
216
239
  // or rewrites a neighbour's entry, so a racing runner can lose nothing
217
240
  if (r.ttlMs > 0) {
218
- if (cacheable) writeStored(path, key, lines, this.now())
241
+ if (cacheable) writeStored(path, key, lines, this.now(), keepsFor)
219
242
  // an unmeasured run evicts whatever was stored: a later run must
220
243
  // re-probe rather than replay an answer recorded before the outage.
221
244
  // A racing runner may have written a good answer into that same
@@ -24,6 +24,8 @@ import { RULES } from '../rules/rule-registry.ts'
24
24
  import { runRuleLint } from '../rules/rule.ts'
25
25
  import { formatLine, parseWireOutput, type Line } from './wire.ts'
26
26
  import { writeStatus } from './write-status.ts'
27
+ import { gateInventory } from '../ledger/gate-units.ts'
28
+ import { UNIT_TABLE_FILE, writeUnitTable } from '../ledger/unit-table.ts'
27
29
  import { SHARE_ENV } from '../release/releases-share.ts'
28
30
  import { rmSync } from 'fs'
29
31
 
@@ -290,8 +292,17 @@ export async function runCheck(root: string, o: CheckOpts = {}): Promise<number>
290
292
  if (o.status !== false) {
291
293
  // the producing command is named honestly: a checks-only run must not
292
294
  // overwrite the gate verdict with a green nobody gated
293
- const st = writeStatus(root, { name: c.name, command: 'shipkit check', lines: run.lines.map((l) => formatLine(l, false)), exitCode: code })
295
+ const st = writeStatus(root, {
296
+ name: c.name,
297
+ command: 'shipkit check',
298
+ lines: run.lines.map((l) => formatLine(l, false)),
299
+ exitCode: code,
300
+ sidecar: { file: UNIT_TABLE_FILE, says: 'Every unit this repo can run, and whether it blocks you' },
301
+ })
294
302
  if (!st.written) print(`WARN ${st.warn}`)
303
+ const units = writeUnitTable(root, { name: c.name, rows: gateInventory(root, c) })
304
+ if (!units.written) print(`WARN ${units.warn}`)
305
+
295
306
  }
296
307
  return code
297
308
  }
@@ -14,7 +14,8 @@ import { formatLine } from './wire.ts'
14
14
  import { runTasks, selectTasks, type TaskResult } from './task-runner.ts'
15
15
  import { writeStatus } from './write-status.ts'
16
16
  import { withContractsScope } from '../contracts/contracts-once.ts'
17
- import { envTier, FULL_TESTS_ENV, gateUnits, type TestTier } from '../ledger/gate-units.ts'
17
+ import { envTier, FULL_TESTS_ENV, gateInventory, gateUnits, tieredTasks, type TestTier } from '../ledger/gate-units.ts'
18
+ import { UNIT_TABLE_FILE, writeUnitTable } from '../ledger/unit-table.ts'
18
19
  import { Ledger, stampReplayed, type UnitOutcome } from '../ledger/ledger.ts'
19
20
 
20
21
  export type CiOpts = {
@@ -294,8 +295,17 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
294
295
  if (o.status !== false) {
295
296
  // scope is named in the file: ci proves the gate, not repo health, and
296
297
  // a bare GREEN here would claim clear-to-ship on a repo with red checks
297
- const st = writeStatus(root, { name: c.name, ...ciScope(o), lines: collected, exitCode: code })
298
+ const st = writeStatus(root, {
299
+ name: c.name,
300
+ ...ciScope(o),
301
+ lines: collected,
302
+ exitCode: code,
303
+ sidecar: { file: UNIT_TABLE_FILE, says: 'Every unit this repo can run, and whether it blocks you' },
304
+ })
298
305
  if (!st.written) print(`WARN ${st.warn}`)
306
+ const units = writeUnitTable(root, { name: c.name, rows: gateInventory(root, c, tieredTasks(c, root, tier)) })
307
+ if (!units.written) print(`WARN ${units.warn}`)
308
+
299
309
  }
300
310
  if (o.json === true)
301
311
  print(JSON.stringify({ ok: summary.ok, interrupted: summary.interrupted, disabled, tasks: summary.results.map((r) => ({ ...r, lines: undefined })) }))
@@ -18,6 +18,10 @@ export type StatusInput = {
18
18
  /** the run's printed lines (ANSI stripped here) */
19
19
  lines: string[]
20
20
  exitCode: number
21
+ /** a companion file this run also refreshed, named so a reader finds it.
22
+ * Optional because a caller that does not write one must not advertise
23
+ * a file that is not there. */
24
+ sidecar?: { file: string; says: string }
21
25
  }
22
26
 
23
27
  export type StatusOutcome = { written: boolean; warn?: string }
@@ -61,7 +65,7 @@ export function writeStatus(root: string, s: StatusInput): StatusOutcome {
61
65
  # ${s.name} status, ${stamp}
62
66
 
63
67
  **${verdict}**
64
-
68
+ ${s.sidecar == null ? '' : `\n${s.sidecar.says}: [${s.sidecar.file}](${s.sidecar.file})\n`}
65
69
  \`\`\`
66
70
  ${clean.join('\n').trim()}
67
71
  \`\`\`