rvlib-shipkit 0.35.7 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,9 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.36.0
4
+
5
+ - **`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.
6
+
7
+ - **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.
8
+
9
+ - **`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.
10
+
11
+ - **`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.
12
+ - **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.
13
+ - 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.
14
+
15
+ - **`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.
16
+ - **`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.
17
+ - **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.
18
+ - `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.
19
+
20
+ ## 0.35.8
21
+
22
+ - **`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.
23
+ - **`--only <check>` ran the check and then exited 1 saying it did not exist.** `selectTasks` validates the flag against the TASK list alone, so naming a discovered check or a rule ran it and still printed `unknown task id`. The flag addresses three surfaces, so the task runner is now handed the ids owned by the other two (rule ids, rule titles, check names, check paths). This is what makes the opt-out safe to use: a check taken off the gate stays reachable by name.
24
+ - The `run-check.ts` comment on the release-survey share path no longer describes a pre-warm `runCi` deleted, and says who the single owner is.
25
+
3
26
  ## 0.35.7
4
27
 
5
28
  - **`shipkit ci` no longer pre-warms a release survey nothing in the gate reads.** `runCi` started `collectReports` next to the tasks and awaited it before the checks, to hide ~3s of npm registry and `gh` network behind the task DAG. Its only consumer was ever the lib-drift probe, which asks what is PUBLISHED and therefore belongs to `shipkit releases` and the bump gate rather than to a gate that asks whether this change is good. Measured on a repo whose drift checks had left the gate: warm no-op 4.3s with the pre-warm, 1.2s without. Deleted rather than made conditional, because nothing is left to condition it on and a second mechanism guessing which check wants a survey is exactly the source-sniffing this scanner already retired. `collectChecks` creates, shares and cleans up the share path when it was not handed one, so the checks that DO want a survey still pay for exactly one between them, and `shipkit check` is unchanged. Honest cost: a repo that keeps a lib-drift check inside its gate loses the overlap and is up to 3s slower there.
6
- - **`export const gate = false` was parsed, filtered on, and never honoured** (the fix itself shipped in 0.35.6, which carried no note for it). The CLI declares `--only` as `Option.Array('--only', [])`, so a plain `shipkit ci` hands the check filter an EMPTY ARRAY and never `undefined`. The guard tested `only == null`, which is false for `[]`, so every gate run read as a narrowed one and no check ever opted out: a repo could declare fifteen world probes off-gate and watch all fifteen run anyway. `matchesOnly`, two hundred lines up in the same file, already used the right predicate, and the `only` field's own doc comment already said "Empty or absent means everything". Both readers now call one exported `isNarrowed`, so `--only` has a single meaning and no second place to get it wrong. Pinned by three tests that call `collectChecks` the way the CLI calls it: the gate skips a `gate = false` check, `shipkit check` still runs it, and naming it in `--only` still reaches it.
29
+ - **`export const gate = false` was parsed, filtered on, and never honoured** (the fix itself shipped in 0.35.6, which carried no note for it). The CLI declares `--only` as `Option.Array('--only', [])`, so a plain `shipkit ci` hands the check filter an EMPTY ARRAY and never `undefined`. The guard tested `only == null`, which is false for `[]`, so every gate run read as a narrowed one and no check ever opted out: a repo could declare fifteen world probes off-gate and watch all fifteen run anyway. `matchesOnly`, two hundred lines up in the same file, already used the right predicate, and the `only` field's own doc comment already said "Empty or absent means everything". Both readers of the CHECK filter now call one exported `isNarrowed`. `--only` also names rules and discovered checks, so the task runner is handed those ids too rather than answering `unknown task id` for a check it was never asked to run. Pinned by three tests that call `collectChecks` the way the CLI calls it: the gate skips a `gate = false` check, `shipkit check` still runs it, and naming it in `--only` still reaches it.
7
30
  - **The adopt path writes `.vscode/tasks.json` alone, and derives what goes in it.** 0.35.6 wrote `settings.json` too, which turns eslint off and hands formatting to an oxc extension: landing that in a repo that lints with eslint silences the linter that repo chose, and that is an answer only its own author gets to give. It also wrote a canned `👁 TSC` watcher pointed at `tsconfig.json`. Against a solution config (`files: []` plus `references`) that watcher needs `--build`, type checks ZERO files without it, and reports a clean problems pane forever, which is worse than no task at all. The TSC subtask is now written only when the root tsconfig resolves inputs, `build/vscode-tasks.md` stays the owner of the per-project analysis for every other watcher, and `init` names that page in its output rather than implying the file is finished.
8
31
  - **A symlinked `.vscode/` is left alone.** It is a dotfiles pattern pointing at a SHARED directory, and writing through it landed files outside the repo being wired.
9
32
  - **A `repo.config.ts` with no `name` no longer kills `init` with an opaque `TypeError`.** The config loader validates nothing and hands back the raw default export, and the port arithmetic iterates the name it is given. That resolution now sits inside the same guard as the write, falls back to the directory name, and a failure there costs one WARN after everything else has already landed.
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.7",
3
+ "version": "0.36.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
 
@@ -78,15 +80,25 @@ export type CheckOpts = {
78
80
  * one they last saw on the wire. */
79
81
  export function matchesOnly(only: string[] | undefined, ...names: string[]): boolean {
80
82
  if (!isNarrowed(only)) return true
81
- return only!.some((want) => names.some((n) => n.toLowerCase() === want.toLowerCase()))
83
+ return only.some((want) => names.some((n) => n.toLowerCase() === want.toLowerCase()))
82
84
  }
83
85
 
84
86
  /** did the operator narrow this run? The ONE reading of `--only` everywhere: an
85
87
  * empty list is not a selection, it is the absence of one. */
86
- export function isNarrowed(only: string[] | undefined): boolean {
88
+ export function isNarrowed(only: string[] | undefined): only is string[] {
87
89
  return only != null && only.length > 0
88
90
  }
89
91
 
92
+ /** every id `--only` can name that is NOT a task: rule ids, rule titles,
93
+ * discovered check names and their repo-relative paths. `--only` addresses all
94
+ * three surfaces, so the task runner has to be told which ids belong to the
95
+ * other two, or it answers `unknown task id` for a check that does run. */
96
+ export function checkSideIds(root: string, c: RepoConfig): string[] {
97
+ const ids = [...RULES, ...(c.rules ?? [])].flatMap((r) => [r.id, r.title])
98
+ for (const d of discoverChecks(root)) ids.push(d.name, relative(root, d.path))
99
+ return ids
100
+ }
101
+
90
102
  export type CheckRun = {
91
103
  /** wire lines, with non-blocking failures already downgraded to warnings */
92
104
  lines: Line[]
@@ -194,12 +206,13 @@ export async function collectChecks(root: string, o: CheckOpts = {}): Promise<Ch
194
206
  logic: async (t) => { await runRuleLint(rule, t, { root, config: c }); demote(t, rule.blocking, rule.title) },
195
207
  })
196
208
  }
197
- // ONE release survey for the whole run: five discovered checks call
198
- // collectReports, each in its own subprocess, and the survey is ~4s of
209
+ // ONE release survey for the whole run: several discovered checks call
210
+ // collectReports, each in its own subprocess, and the survey is ~3s of
199
211
  // network. The path is per RUN and never reused across runs, so nothing
200
212
  // here can serve a stale answer to a later gate (releases-share.ts).
201
- // a caller that already opened one (runCi, which starts the survey next to
202
- // the tasks) owns it, cleanup included; otherwise this run owns its own
213
+ // This is the ONLY owner: nothing else in shipkit sets the variable, and an
214
+ // inherited one can only come from a caller outside the binary, which then
215
+ // owns its own cleanup.
203
216
  const inherited = process.env[SHARE_ENV]
204
217
  const sharePath = inherited != null && inherited !== '' ? inherited : join(root, 'tmp/shipkit-ci', `releases-${process.pid}-${Date.now()}.json`)
205
218
  const childEnv: Record<string, string | undefined> = {
@@ -279,8 +292,17 @@ export async function runCheck(root: string, o: CheckOpts = {}): Promise<number>
279
292
  if (o.status !== false) {
280
293
  // the producing command is named honestly: a checks-only run must not
281
294
  // overwrite the gate verdict with a green nobody gated
282
- 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
+ })
283
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
+
284
306
  }
285
307
  return code
286
308
  }