rvlib-shipkit 0.31.0 → 0.32.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 +9 -0
- package/README.md +3 -0
- package/package.json +1 -1
- package/src/cli/cmd_Bump.ts +2 -0
- package/src/cli/cmd_Ci.ts +3 -1
- package/src/cli/cmd_Pb.ts +27 -0
- package/src/cli/cmd_Release.ts +1 -1
- package/src/cli/cmd_Wiki.ts +2 -2
- package/src/cli.ts +49 -39
- package/src/collections/providers.ts +7 -2
- package/src/contracts/contracts-once.ts +43 -0
- package/src/core/canonical-scripts.ts +33 -0
- package/src/core/check-ctx.ts +13 -0
- package/src/core/check-suite.ts +121 -53
- package/src/core/floor.ts +82 -0
- package/src/core/motherhome.ts +14 -1
- package/src/core/run-check.ts +34 -0
- package/src/core/run-ci.ts +63 -2
- package/src/core/run-fixers.ts +6 -1
- package/src/ledger/toolchain-identity.ts +134 -0
- package/src/pb/collection-scaffold.ts +299 -0
- package/src/pb/pb-types.ts +233 -0
- package/src/pb/run-pb.ts +157 -0
- package/src/release/git-facts.ts +3 -2
- package/src/release/release-scope.ts +52 -0
- package/src/release/releases-share.ts +85 -0
- package/src/release/run-bump.ts +31 -8
- package/src/release/run-release.ts +8 -1
- package/src/release/run-releases.ts +20 -6
- package/src/repo-config.ts +22 -65
- package/src/rules/SK001-package-json-shape.ts +65 -2
- package/src/rules/SK002-contracts-fulfillness.ts +2 -2
- package/src/rules/SK004-gitignore-policy.ts +27 -3
- package/src/rules/SK012-config-barrel-import.ts +60 -0
- package/src/rules/rule-registry.ts +2 -0
- package/src/rules/untrack-generated.ts +117 -0
- package/src/scaffold/project-scaffold.ts +1 -1
- package/src/serve/hub-port.ts +17 -0
- package/src/serve/run-serve.ts +2 -5
- package/src/wiki/run-wiki.ts +18 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.32.0
|
|
4
|
+
|
|
5
|
+
- **`shipkit pb collection <name>` scaffolds a PocketBase collection's whole UI folder.** Building a CRUD screen meant retyping a schema that already exists in the app's generated types, one column at a time, and the boring half of that is exactly where a column goes missing. The command reads `src/pb.types.ts` (`.tsx` and `.d.ts` too), resolves one collection, and writes `src/collections/<name>/`: `columns.tsx` with one entry per response field, `card.tsx`, `grid.tsx` over `LiveGrid`, `form.tsx` (a create/edit modal writing through `db` fire and forget), and an `index.ts` calling `defineCollectionUI`. It then registers the folder in `src/collections/index.ts`, creating that file when it is the first one, because a collection folder nothing imports is dead code whose failure mode is a runtime throw. A field the schema cannot turn into a cell on its own (relation, file, json, raw html) is emitted COMMENTED with the reason rather than dropped, because a missing column is invisible and a commented one takes ten seconds to accept or delete. The card leads on the fields a human reads, not on the first three of an alphabetical record block, and the label picks the first of `name`, `label`, `title`, `number`, `code`, `email`, else `id`.
|
|
6
|
+
- **The output is the house dialect, not a second one.** `X.` Mantine globals rather than `@mantine/core` imports, `observer(function Name_())` around the form, the `{ record?, onClose }` props `CollectionUI['form']` actually declares, and `defineCollectionUI` called as the void statement it is. A generator that invents its own shape is worse than no generator: what it writes is what the next developer copies. `key` is set only where the server can really order by the column, so a joined multi-value cell no longer claims sortability it does not have, and an auth collection's `verified`, `email`, `username` and `emailVisibility` get no control, since PocketBase routes those through endpoints a plain patch never reaches.
|
|
7
|
+
- **It refuses rather than half-writing.** No generated types, a file that is not typegen output, a collection the schema does not carry, one of PocketBase's own `_`-prefixed internals, a record block that is missing or unreadable, or a block where not one field parsed: each exits non-zero naming the file, the known collection names and the typegen command that produces them. A single field whose type is unrecognised is named in a `WARN` instead of quietly becoming a commented column. Writes are create-only: a path that already exists is reported and left byte for byte as it is, so a re-run after a schema change can never eat hand-written UI. `--dry-run` prints the plan AND every file body in full and touches nothing, which is what an agent should reach for first.
|
|
8
|
+
- **A path shipkit generates is ignored AND untracked, in one operation.** `.gitignore` does nothing to a file that is already tracked, so a generated path that is only ignored keeps dirtying every tree forever and rides into unrelated commits as a mystery diff. Rule SK004 now FAILs while a declared generated path is still in the index, and its autofix removes it with `git rm --cached` and commits exactly that, `chore(machine): untrack <path>`, through the hooks. The file stays on disk. First customers: `STATUS.md`, `STATUS.md.lock` and `.STATUS.md.tmp-*`, which shipkit rewrites on every run and nothing ever reads a past version of. Only literal paths are removable, never a glob, because `git rm --cached` driven by a wildcard is how a fixer eats source. It refuses, by name, mid-merge, mid-rebase, on a detached HEAD, before the first commit, and whenever anything else is already staged, so the migration commit can never sweep up someone's work. A hook that rejects it restores the index rather than leaving a staged deletion behind, which is the failure mode it exists to stop. `--frozen` suppresses it and `untrackGenerated: false` opts a repo out.
|
|
9
|
+
- **Two test tiers, and every run says which one it is.** `shipkit ci` runs the QUICK tier: pure logic, no child process, no repository on disk, so the working gate stays fast. `shipkit bump` and `shipkit release` always run the FULL tier, before the version commit, so a red suite aborts the release instead of leaving a half-published repo. `shipkit ci --full` (or `SHIPKIT_TEST_FULL=1`, which the gate sets for every task) runs it by hand. Both directions print a tier line and the test count actually run, because a log that does not name the tier cannot be told from one that quietly skipped half the suite.
|
|
10
|
+
- Reading the schema is a LINE parse of generated code, not a TypeScript AST: shipkit carries no typescript dependency and typegen writes one field per line. Record blocks parameterised by a json field are handled, and so is a file a formatter has rewritten with trailing semicolons, which is the normal state of any repo whose formatter covers `src/**`.
|
|
11
|
+
|
|
3
12
|
## 0.31.0
|
|
4
13
|
|
|
5
14
|
- **Every repo browses its shared wiki from its own hub.** A repo whose CLAUDE.md include chain points at a wiki living in another repo (`@~/dev/<other-repo>/wiki/index.md`, read from `CLAUDE.md`, `CLAUDE.local.md` or `CLAUDE.private.md`) mounts it at `/graph/base`, named after the repo that owns it. That wiki binds every agent working here, so the hub lets a human read it where the work happens. An index inside the repo is skipped, since it is already its own mount, and the card carries no lint verdict: the owning repo lints its own pages.
|
package/README.md
CHANGED
|
@@ -46,6 +46,9 @@ shipkit wiki serve|lint — live md link-graph + orphan/dangling-ref lint
|
|
|
46
46
|
shipkit mv rename an md page + its sibling dir, sweeping every ref
|
|
47
47
|
shipkit assets serve|fill|add|gen|identity|check — the asset atelier
|
|
48
48
|
(declare kinds in .rv-assets/kit.ts, roll, curate, ship)
|
|
49
|
+
shipkit pb collection <name> — scaffold a PocketBase collection's UI
|
|
50
|
+
folder (columns/card/grid/form/index) from the app's
|
|
51
|
+
generated pb types, create-only [--dry-run]
|
|
49
52
|
shipkit serve the repo's ONE server: hub page, graphs, studio, status
|
|
50
53
|
shipkit reflect your prompts across every Claude Code session of a repo:
|
|
51
54
|
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.
|
|
3
|
+
"version": "0.32.0",
|
|
4
4
|
"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",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/cli/cmd_Bump.ts
CHANGED
|
@@ -32,6 +32,7 @@ export class BumpCommand extends Command {
|
|
|
32
32
|
dryRun = Option.Boolean('--dry-run', false, { description: 'rehearse: run every preflight, the gate and the prepublish scripts, then stop before any version is written. the gate can rewrite files, and the merge is probed rather than performed, so the release branch never moves' })
|
|
33
33
|
noRelease = Option.Boolean('--no-release', false, { description: 'stop after the version commit, do not tag or publish' })
|
|
34
34
|
noMerge = Option.Boolean('--no-merge', false, { description: 'refuse on a feature branch instead of merging it into the release branch' })
|
|
35
|
+
allowDirty = Option.Boolean('--allow-dirty', false, { description: "proceed with uncommitted changes inside what this release writes. Dirt in OTHER units is already ignored, so this is only for when the scoping judges wrong. A sub-unit's OWN directory still refuses, the same as `release`, and no flag lifts that: `npm publish` packs the WORKING TREE rather than the commit, so forcing past it ships the half-written file under a tag that describes something else" })
|
|
35
36
|
all = Option.Boolean('--all', false, { description: 'bump every unit whose changelog carries Unreleased notes, in declared order, root last. Given a kind it applies to ALL of them. Given none, each unit takes the kind from its own "## Unreleased (minor)" heading, and a unit whose heading is silent refuses' })
|
|
36
37
|
|
|
37
38
|
async execute(): Promise<number> {
|
|
@@ -51,6 +52,7 @@ export class BumpCommand extends Command {
|
|
|
51
52
|
noMerge: this.noMerge,
|
|
52
53
|
unit,
|
|
53
54
|
all: this.all,
|
|
55
|
+
allowDirty: this.allowDirty,
|
|
54
56
|
print: (l) => this.context.stdout.write(`${l}\n`),
|
|
55
57
|
})
|
|
56
58
|
} catch (e) {
|
package/src/cli/cmd_Ci.ts
CHANGED
|
@@ -24,10 +24,11 @@ export class CiCommand extends Command {
|
|
|
24
24
|
json = Option.Boolean('--json', false, { description: 'machine-readable result instead of wire lines' })
|
|
25
25
|
fix = Option.Boolean('--fix', true, { description: 'repair the mechanical slots before gating. Default on. --no-fix skips the repair pass, checks can still write. Under CI the fixers still run but the gate FAILS on any file they rewrite' })
|
|
26
26
|
fixRules = Option.Boolean('--fix-rules', true, { description: 'apply the structural rule autofixes too. Default on. --no-fix-rules leaves repo config alone' })
|
|
27
|
-
frozen = Option.Boolean('--frozen', false, { description: 'report-only mode for self-maintaining rules
|
|
27
|
+
frozen = Option.Boolean('--frozen', false, { description: 'report-only mode for self-maintaining rules: SK005 never touches package.json, and generated paths are never untracked. STATUS.md is still refreshed on disk, but it is gitignored, so a run leaves the tree clean' })
|
|
28
28
|
skip = Option.Array('--skip', [], { description: 'skip a task group (repeatable)' })
|
|
29
29
|
withIds = Option.Array('--with', [], { description: 'include an opt-in task (repeatable)' })
|
|
30
30
|
only = Option.Array('--only', [], { description: 'run only this task + its dep closure (repeatable)' })
|
|
31
|
+
full = Option.Boolean('--full', false, { description: `run the FULL test tier: every suite the repo declares, the slow ones it skips while you work included. What the release path always runs. Same as SHIPKIT_TEST_FULL=1` })
|
|
31
32
|
|
|
32
33
|
async execute(): Promise<number> {
|
|
33
34
|
// a stale toolkit updates itself and restarts this exact invocation
|
|
@@ -44,6 +45,7 @@ export class CiCommand extends Command {
|
|
|
44
45
|
skipGroups: this.skip,
|
|
45
46
|
withIds: this.withIds,
|
|
46
47
|
onlyIds: this.only,
|
|
48
|
+
...(this.full ? { tests: 'full' as const } : {}),
|
|
47
49
|
print: (l) => this.context.stdout.write(`${l}\n`),
|
|
48
50
|
})
|
|
49
51
|
} catch (e) {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// shipkit pb — scaffolding for a PocketBase app, run from THAT app's root.
|
|
2
|
+
// Thin clipanion surface; the schema parse and the emitters live in pb/.
|
|
3
|
+
import { Command, Option } from 'clipanion'
|
|
4
|
+
import { runPbCollection } from '../pb/run-pb.ts'
|
|
5
|
+
|
|
6
|
+
export class PbCollectionCommand extends Command {
|
|
7
|
+
static override paths = [['pb', 'collection']]
|
|
8
|
+
|
|
9
|
+
static override usage = Command.Usage({
|
|
10
|
+
category: 'Scaffold',
|
|
11
|
+
description: "a collection's CRUD UI folder from the app's generated pb types: columns, card, grid, form, index",
|
|
12
|
+
examples: [
|
|
13
|
+
['see the whole folder before it exists', 'shipkit pb collection orders --dry-run'],
|
|
14
|
+
['write src/collections/orders/', 'shipkit pb collection orders'],
|
|
15
|
+
],
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
name = Option.String()
|
|
19
|
+
dryRun = Option.Boolean('--dry-run', false, { description: 'print every file in full, write nothing' })
|
|
20
|
+
|
|
21
|
+
async execute(): Promise<number> {
|
|
22
|
+
return runPbCollection(process.cwd(), this.name, {
|
|
23
|
+
print: (l) => this.context.stdout.write(`${l}\n`),
|
|
24
|
+
printErr: (l) => this.context.stderr.write(`${l}\n`),
|
|
25
|
+
}, { dryRun: this.dryRun })
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/cli/cmd_Release.ts
CHANGED
|
@@ -20,7 +20,7 @@ export class ReleaseCommand extends Command {
|
|
|
20
20
|
|
|
21
21
|
unit = Option.String({ required: false })
|
|
22
22
|
dryRun = Option.Boolean('--dry-run', false, { description: 'run every preflight and print the plan, change nothing' })
|
|
23
|
-
allowDirty = Option.Boolean('--allow-dirty', false, { description: 'release from an unclean tree (the tag will not contain it).
|
|
23
|
+
allowDirty = Option.Boolean('--allow-dirty', false, { description: 'release from an unclean tree (the tag will not contain it). Safe for dirt in OTHER paths only: `npm publish` packs the WORKING TREE, not the commit, so a SUB-unit still refuses on dirt inside its own path rather than ship a half-written file under a tag that describes something else' })
|
|
24
24
|
skipGate = Option.Boolean('--skip-gate', false, { description: 'skip the ci gate preflight — publishes unverified, use only to recover a stuck release' })
|
|
25
25
|
|
|
26
26
|
async execute(): Promise<number> {
|
package/src/cli/cmd_Wiki.ts
CHANGED
|
@@ -27,7 +27,7 @@ export class WikiServeCommand extends Command {
|
|
|
27
27
|
this.context.stderr.write(`FAIL --port '${this.port}' is not a port number\n`)
|
|
28
28
|
return 1
|
|
29
29
|
}
|
|
30
|
-
return runWikiServe({ root: this.root, index: this.index, skip: this.skip, port, title: this.title })
|
|
30
|
+
return runWikiServe({ root: this.root, index: this.index, skip: this.skip, port, title: this.title, print: (l) => this.context.stdout.write(`${l}\n`) })
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
@@ -48,6 +48,6 @@ export class WikiLintCommand extends Command {
|
|
|
48
48
|
skip = Option.String('--skip', { required: false, description: 'comma-separated dir names to exclude from the scan' })
|
|
49
49
|
|
|
50
50
|
async execute(): Promise<number> {
|
|
51
|
-
return runWikiLint({ root: this.root, index: this.index, skip: this.skip })
|
|
51
|
+
return runWikiLint({ root: this.root, index: this.index, skip: this.skip, print: (l) => this.context.stdout.write(`${l}\n`) })
|
|
52
52
|
}
|
|
53
53
|
}
|
package/src/cli.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { HooksCommitMsgCommand, HooksInstallCommand, HooksListCommand, HooksPreC
|
|
|
15
15
|
import { InitCommand } from './cli/cmd_Init.ts'
|
|
16
16
|
import { MvCommand } from './cli/cmd_Mv.ts'
|
|
17
17
|
import { NeedsCommand } from './cli/cmd_Needs.ts'
|
|
18
|
+
import { PbCollectionCommand } from './cli/cmd_Pb.ts'
|
|
18
19
|
import { ReflectCommand } from './cli/cmd_Reflect.ts'
|
|
19
20
|
import { ReleaseCommand } from './cli/cmd_Release.ts'
|
|
20
21
|
import { ReleasesCommand } from './cli/cmd_Releases.ts'
|
|
@@ -26,44 +27,53 @@ import { WikiLintCommand, WikiServeCommand } from './cli/cmd_Wiki.ts'
|
|
|
26
27
|
|
|
27
28
|
const version = (JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }).version
|
|
28
29
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
30
|
+
/** the registry, built but NOT run: a test drives the real binary in process
|
|
31
|
+
* instead of paying a cold bun start per assertion (~250ms each), and the
|
|
32
|
+
* wiring it exercises is the wiring that ships. */
|
|
33
|
+
export function buildCli(): Cli {
|
|
34
|
+
const cli = new Cli({
|
|
35
|
+
binaryLabel: 'shipkit — repo toolkit',
|
|
36
|
+
binaryName: 'shipkit',
|
|
37
|
+
binaryVersion: version,
|
|
38
|
+
enableCapture: true, // console output pipes into the command streams (testable)
|
|
39
|
+
enableColors: true,
|
|
40
|
+
})
|
|
36
41
|
|
|
37
|
-
cli.register(Builtins.HelpCommand)
|
|
38
|
-
cli.register(Builtins.VersionCommand)
|
|
39
|
-
cli.register(CiCommand)
|
|
40
|
-
cli.register(BumpCommand)
|
|
41
|
-
cli.register(ReleaseCommand)
|
|
42
|
-
cli.register(ReleasesCommand)
|
|
43
|
-
cli.register(HooksInstallCommand)
|
|
44
|
-
cli.register(HooksPreCommitCommand)
|
|
45
|
-
cli.register(HooksCommitMsgCommand)
|
|
46
|
-
cli.register(HooksListCommand)
|
|
47
|
-
cli.register(MvCommand)
|
|
48
|
-
cli.register(CheckCommand)
|
|
49
|
-
cli.register(TuiCommand)
|
|
50
|
-
cli.register(ServeCommand)
|
|
51
|
-
cli.register(ContractsCommand)
|
|
52
|
-
cli.register(AsksCommand)
|
|
53
|
-
cli.register(AsksCheckCommand)
|
|
54
|
-
cli.register(AskNewCommand)
|
|
55
|
-
cli.register(WikiServeCommand)
|
|
56
|
-
cli.register(WikiLintCommand)
|
|
57
|
-
cli.register(AssetsFillCommand)
|
|
58
|
-
cli.register(AssetsAddCommand)
|
|
59
|
-
cli.register(AssetsGenCommand)
|
|
60
|
-
cli.register(AssetsIdentityCommand)
|
|
61
|
-
cli.register(AssetsCheckCommand)
|
|
62
|
-
cli.register(InitCommand)
|
|
63
|
-
cli.register(StartCommand)
|
|
64
|
-
cli.register(NeedsCommand)
|
|
65
|
-
cli.register(
|
|
66
|
-
cli.register(
|
|
67
|
-
cli.register(
|
|
42
|
+
cli.register(Builtins.HelpCommand)
|
|
43
|
+
cli.register(Builtins.VersionCommand)
|
|
44
|
+
cli.register(CiCommand)
|
|
45
|
+
cli.register(BumpCommand)
|
|
46
|
+
cli.register(ReleaseCommand)
|
|
47
|
+
cli.register(ReleasesCommand)
|
|
48
|
+
cli.register(HooksInstallCommand)
|
|
49
|
+
cli.register(HooksPreCommitCommand)
|
|
50
|
+
cli.register(HooksCommitMsgCommand)
|
|
51
|
+
cli.register(HooksListCommand)
|
|
52
|
+
cli.register(MvCommand)
|
|
53
|
+
cli.register(CheckCommand)
|
|
54
|
+
cli.register(TuiCommand)
|
|
55
|
+
cli.register(ServeCommand)
|
|
56
|
+
cli.register(ContractsCommand)
|
|
57
|
+
cli.register(AsksCommand)
|
|
58
|
+
cli.register(AsksCheckCommand)
|
|
59
|
+
cli.register(AskNewCommand)
|
|
60
|
+
cli.register(WikiServeCommand)
|
|
61
|
+
cli.register(WikiLintCommand)
|
|
62
|
+
cli.register(AssetsFillCommand)
|
|
63
|
+
cli.register(AssetsAddCommand)
|
|
64
|
+
cli.register(AssetsGenCommand)
|
|
65
|
+
cli.register(AssetsIdentityCommand)
|
|
66
|
+
cli.register(AssetsCheckCommand)
|
|
67
|
+
cli.register(InitCommand)
|
|
68
|
+
cli.register(StartCommand)
|
|
69
|
+
cli.register(NeedsCommand)
|
|
70
|
+
cli.register(PbCollectionCommand)
|
|
71
|
+
cli.register(ReflectCommand)
|
|
72
|
+
cli.register(RulesCommand)
|
|
73
|
+
cli.register(CommandsCommand)
|
|
68
74
|
|
|
69
|
-
cli
|
|
75
|
+
return cli
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// only when this file IS the process, so importing it costs nothing
|
|
79
|
+
if (import.meta.main) void buildCli().runExit(process.argv.slice(2))
|
|
@@ -13,7 +13,10 @@ import { join } from 'path'
|
|
|
13
13
|
import { isStale } from '../assets/gen/derived.ts'
|
|
14
14
|
import { allAssets, mediaOf, type ResolvedKit } from '../assets/kit-config.ts'
|
|
15
15
|
import { assetDir, listRolls, readPick } from '../assets/roll-workspace.ts'
|
|
16
|
-
import
|
|
16
|
+
// TYPE only, plus a lazy import at the call site. run-assets pulls the comfy
|
|
17
|
+
// runtime and the canvas binding, ~400ms of module load, and the hub's
|
|
18
|
+
// collection list is built on every request by callers that mostly never heal
|
|
19
|
+
import type { healIdentity } from '../assets/run-assets.ts'
|
|
17
20
|
import { doneAsks, openAsks } from '../asks/asks-queue.ts'
|
|
18
21
|
import { evaluateContracts } from '../contracts/contracts-engine.ts'
|
|
19
22
|
import { parseWireOutput } from '../core/wire.ts'
|
|
@@ -171,7 +174,9 @@ const credInstance = (name: string, cmdTemplate: string | undefined, decl?: { se
|
|
|
171
174
|
* Identity self-heals BEFORE surfacing; manual with nothing uploaded =
|
|
172
175
|
* red (provide); rolls with no pick = yellow (a default exists, taste
|
|
173
176
|
* pending — auto-pick-first is blessed); stale = yellow. */
|
|
174
|
-
|
|
177
|
+
const healLazily: typeof healIdentity = async (root) => (await import('../assets/run-assets.ts')).healIdentity(root)
|
|
178
|
+
|
|
179
|
+
export function mediaKitCollection(root: string, kit: ResolvedKit | null, heal: typeof healIdentity = healLazily): Collection {
|
|
175
180
|
return {
|
|
176
181
|
id: 'media',
|
|
177
182
|
title: 'assets',
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// ONE contract evaluation per gate run.
|
|
2
|
+
//
|
|
3
|
+
// Two surfaces report fulfillness inside a single `shipkit ci`: the `contracts`
|
|
4
|
+
// TASK, which exists because deploy tasks declare `deps: ['contracts']` and so
|
|
5
|
+
// need a real ordering edge in the DAG, and rule SK002, which exists because a
|
|
6
|
+
// `shipkit check` with no task phase must still report it. Both are load
|
|
7
|
+
// bearing and neither can be deleted: dropping the task would silently ungate
|
|
8
|
+
// every deploy that depends on it, since checks run AFTER every task.
|
|
9
|
+
//
|
|
10
|
+
// So they stay two REPORTS of one evaluation. The task runs first and its
|
|
11
|
+
// result is what SK002 reads. THE TASK IS AUTHORITATIVE (contracts.md says so
|
|
12
|
+
// too); SK002 evaluating on its own is the fallback for a run with no task
|
|
13
|
+
// phase, not a second opinion.
|
|
14
|
+
//
|
|
15
|
+
// The scope is EXPLICIT and never ambient. A memo that outlived its run would
|
|
16
|
+
// let the hub, which rebuilds its collection list on every request, answer
|
|
17
|
+
// from a snapshot of whatever the last gate saw. Only a caller that owns a run
|
|
18
|
+
// opens one.
|
|
19
|
+
import { evaluateContracts, type ContractsConfig, type ContractsResult } from './contracts-engine.ts'
|
|
20
|
+
import type { ContractManifest } from './contract-types.ts'
|
|
21
|
+
|
|
22
|
+
let open = false
|
|
23
|
+
let cached: { key: string; result: Promise<ContractsResult> } | null = null
|
|
24
|
+
|
|
25
|
+
/** run `fn` as ONE gate run: contract evaluation inside it happens once. */
|
|
26
|
+
export async function withContractsScope<T>(fn: () => Promise<T>): Promise<T> {
|
|
27
|
+
const outer = open
|
|
28
|
+
open = true
|
|
29
|
+
try { return await fn() } finally { if (!outer) { open = false; cached = null } }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** the evaluation, memoised only while a run scope is open. Outside one it is
|
|
33
|
+
* a plain call, so `shipkit contracts` and the hub always measure reality. */
|
|
34
|
+
export function evaluateContractsOnce(manifests: ContractManifest[], cfg: ContractsConfig): Promise<ContractsResult> {
|
|
35
|
+
if (!open) return evaluateContracts(manifests, cfg)
|
|
36
|
+
// the config decides the verdict, so it belongs in the identity: a run that
|
|
37
|
+
// somehow evaluated two different configs must not serve one for the other
|
|
38
|
+
const key = JSON.stringify(cfg)
|
|
39
|
+
if (cached != null && cached.key === key) return cached.result
|
|
40
|
+
const result = evaluateContracts(manifests, cfg)
|
|
41
|
+
cached = { key, result }
|
|
42
|
+
return result
|
|
43
|
+
}
|
|
@@ -30,6 +30,39 @@ export function conditionalBodies(_opts: ScriptStyle = {}): Record<string, strin
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
// ONE WAY IN (build/shipkit.md): the verification verbs an agent types from
|
|
34
|
+
// muscle memory ROUTE to shipkit, so `bun run test` gets the cache and the
|
|
35
|
+
// job attachment without anyone having to remember a rule. The tool body they
|
|
36
|
+
// used to hold moves to `<name>:raw`, which is what shipkit itself runs and
|
|
37
|
+
// what an agent uses to iterate on one repro file.
|
|
38
|
+
//
|
|
39
|
+
// only these three. `imports:check` and `cycles` are shipkit's own vocabulary
|
|
40
|
+
// (nobody reaches for them by reflex) and `fmt` is the slot shipkit's fix pass
|
|
41
|
+
// invokes, so routing it would make the fixer call itself.
|
|
42
|
+
export const ROUTED_SCRIPTS = ['test', 'lint', 'typecheck'] as const
|
|
43
|
+
|
|
44
|
+
export const RAW_SUFFIX = ':raw'
|
|
45
|
+
|
|
46
|
+
/** where the tool body lives once `<id>` routes to shipkit */
|
|
47
|
+
export const rawName = (id: string): string => `${id}${RAW_SUFFIX}`
|
|
48
|
+
|
|
49
|
+
/** what a routed `<id>` script says. `--only` narrows to the one question the
|
|
50
|
+
* name promises: `bun run test` must not become a full gate behind the
|
|
51
|
+
* agent's back */
|
|
52
|
+
export function routedBody(id: string, opts: ScriptStyle = {}): string {
|
|
53
|
+
return `${opts.bin != null ? `bun ${opts.bin}` : 'shipkit'} ci --only ${id}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** does this body hand the question to `shipkit ci` rather than to a tool ?
|
|
57
|
+
* every invocation form counts: the bin, `bunx rvlib-shipkit`, an in-repo
|
|
58
|
+
* `…/cli.ts` path. A body that does is never run AS a floor task, on pain of
|
|
59
|
+
* infinite recursion. The `ci` half is load-bearing: a body that merely lives
|
|
60
|
+
* under a shipkit checkout (a repo calling rvlib's source-graph cli by path)
|
|
61
|
+
* is not a route back in. */
|
|
62
|
+
export function routesToShipkit(body: string): boolean {
|
|
63
|
+
return /\bshipkit\b/.test(body) && /\bci\b/.test(body)
|
|
64
|
+
}
|
|
65
|
+
|
|
33
66
|
// recover the consumption style from existing scripts: any shipkit command
|
|
34
67
|
// already running `bun <path>/cli.ts <cmd>` fixes the style for new bodies
|
|
35
68
|
export function inferBin(scripts: Record<string, string>): string | undefined {
|
package/src/core/check-ctx.ts
CHANGED
|
@@ -13,10 +13,23 @@ export class CheckCtx {
|
|
|
13
13
|
lines: Line[] = []
|
|
14
14
|
failed = false
|
|
15
15
|
warned = false
|
|
16
|
+
/** THIS RUN PRODUCED NO ANSWER: the probe could not run at all (network
|
|
17
|
+
* down, no cred, rate limited, tool missing). Distinct from a probe that
|
|
18
|
+
* ran and came back bad, which IS an answer. The cache replays answers
|
|
19
|
+
* and never replays their absence (check-suite.ts, the measured-negative
|
|
20
|
+
* rule). Set it whenever the reason a check is not green is that it never
|
|
21
|
+
* reached the thing it was asking about. */
|
|
22
|
+
unmeasured = false
|
|
16
23
|
constructor(private toolPaths: Map<string, string>) {}
|
|
17
24
|
|
|
18
25
|
ok(msg: string): void { this.lines.push({ kind: 'ok', text: msg }) }
|
|
19
26
|
note(msg: string): void { this.lines.push({ kind: 'note', text: msg }) }
|
|
27
|
+
/** a WARN that came back from something we could not reach — no answer, so
|
|
28
|
+
* nothing to remember */
|
|
29
|
+
unreachable(msg: string, fix?: string): void {
|
|
30
|
+
this.unmeasured = true
|
|
31
|
+
this.warn(msg, fix)
|
|
32
|
+
}
|
|
20
33
|
warn(msg: string, fix?: string): void {
|
|
21
34
|
this.warned = true
|
|
22
35
|
this.lines.push({ kind: 'warn', text: msg })
|
package/src/core/check-suite.ts
CHANGED
|
@@ -15,8 +15,9 @@ import { fmtStamp, formatLine, parseDuration, type Line } from './wire.ts'
|
|
|
15
15
|
|
|
16
16
|
type Logic = (t: CheckCtx) => unknown
|
|
17
17
|
type Prep = { skip?: string; cacheExtra?: string }
|
|
18
|
-
type Runner = { name: string; ttlMs: number; version: number; prep?: () => Prep; logic: Logic }
|
|
19
|
-
type
|
|
18
|
+
type Runner = { name: string; ttlMs: number; version: number; prep?: () => Prep; logic: Logic; blocking: boolean }
|
|
19
|
+
type CacheEntry = { version: number; key: string; at: number; lines: Line[] }
|
|
20
|
+
type CacheFile = Record<string, CacheEntry>
|
|
20
21
|
|
|
21
22
|
export type GetSecret = (name: string, field: 'get' | 'account') => string | null
|
|
22
23
|
|
|
@@ -61,8 +62,12 @@ export class CheckSuite {
|
|
|
61
62
|
this.onResult = opts.onResult
|
|
62
63
|
}
|
|
63
64
|
|
|
64
|
-
|
|
65
|
-
|
|
65
|
+
/** `blocking` decides what a NEGATIVE result may be cached as: see the
|
|
66
|
+
* measured-negative rule in execute(). Default true, the safe side: a
|
|
67
|
+
* check that never declared itself is treated as being about this repo,
|
|
68
|
+
* so its failures are never replayed. */
|
|
69
|
+
addCheck(spec: { name: string; remainValidFor?: string | number; version?: number; cacheExtra?: () => string; logic: Logic; blocking?: boolean }): void {
|
|
70
|
+
this.runners.push({ name: spec.name, ttlMs: spec.remainValidFor != null ? parseDuration(spec.remainValidFor) : 0, version: spec.version ?? 1, prep: spec.cacheExtra != null ? () => ({ cacheExtra: spec.cacheExtra!() }) : undefined, logic: spec.logic, blocking: spec.blocking ?? true })
|
|
66
71
|
}
|
|
67
72
|
|
|
68
73
|
defineRequiredTool(name: string, opts: { candidates?: string[]; probe?: { args: string[]; expect?: RegExp }; fix: string }): void {
|
|
@@ -97,6 +102,12 @@ export class CheckSuite {
|
|
|
97
102
|
const validate = spec.validate as (v: unknown, t: CheckCtx) => unknown
|
|
98
103
|
let payload: unknown
|
|
99
104
|
this.runners.push({
|
|
105
|
+
// conservative on purpose: a secret probe keeps today's behaviour,
|
|
106
|
+
// where no negative is remembered. Its cache key already hashes the
|
|
107
|
+
// secret VALUE, so a rotation busts it, which would make caching a
|
|
108
|
+
// measured 403 sound. That is a severity call for whoever owns the
|
|
109
|
+
// secrets surface, not a side effect of this change.
|
|
110
|
+
blocking: true,
|
|
100
111
|
// bare secret name — this shows up in human-facing WARN/FAIL lines
|
|
101
112
|
name: names.length === 1 ? primary : primary.split('/').slice(0, 2).join('/') + '/*',
|
|
102
113
|
ttlMs: spec.remainValidFor != null ? parseDuration(spec.remainValidFor) : 0,
|
|
@@ -118,65 +129,122 @@ export class CheckSuite {
|
|
|
118
129
|
}
|
|
119
130
|
|
|
120
131
|
// test seam — run() prints + exits; execute() returns the structured result
|
|
132
|
+
/** ONE check: cache replay, run, classify, and what it wants remembered.
|
|
133
|
+
* Pure with respect to its neighbours, which is what lets execute() run
|
|
134
|
+
* them together. */
|
|
135
|
+
private async runOne(r: Runner, cache: CacheFile): Promise<{ lines: Line[]; skip: string | null; ran: boolean; failed: boolean; store: CacheEntry | null; evict: boolean }> {
|
|
136
|
+
const prep = r.prep?.() ?? {}
|
|
137
|
+
if (prep.skip != null) {
|
|
138
|
+
this.onResult?.(r.name, { lines: [{ kind: 'skip', text: prep.skip }], status: 'skip', cached: false })
|
|
139
|
+
return { lines: [], skip: prep.skip, ran: false, failed: false, store: null, evict: false }
|
|
140
|
+
}
|
|
141
|
+
const key = prep.cacheExtra ?? ''
|
|
142
|
+
const entry = cache[r.name]
|
|
143
|
+
const fresh = r.ttlMs > 0 && process.env[this.freshEnv] == null
|
|
144
|
+
&& entry != null && entry.version === r.version && entry.key === key
|
|
145
|
+
&& this.now() - entry.at < r.ttlMs && this.now() >= entry.at
|
|
146
|
+
if (fresh) {
|
|
147
|
+
// the stamp was baked in when the check RAN (below): a replay
|
|
148
|
+
// prints the stored lines VERBATIM, so the live run and every
|
|
149
|
+
// later replay render the same string, and the line changes
|
|
150
|
+
// only when a re-validation happens. Annotating replays but
|
|
151
|
+
// not live runs still diffed on every fresh-vs-cached
|
|
152
|
+
// alternation, so neither side annotates
|
|
153
|
+
// a replayed WORLD negative keeps its severity: it is still the
|
|
154
|
+
// answer, and demoting it to a pass would hide a real finding
|
|
155
|
+
const replayedWarn = entry.lines.some((l) => l.kind === 'warn' || l.kind === 'fail')
|
|
156
|
+
this.onResult?.(r.name, { lines: entry.lines, status: replayedWarn ? 'warn' : 'pass', cached: true })
|
|
157
|
+
return { lines: entry.lines, skip: null, ran: true, failed: false, store: null, evict: false }
|
|
158
|
+
}
|
|
159
|
+
const t = new CheckCtx(this.toolPaths)
|
|
160
|
+
try {
|
|
161
|
+
await r.logic(t)
|
|
162
|
+
} catch (e) {
|
|
163
|
+
// both of these mean the probe never reached its subject, so the
|
|
164
|
+
// run has no answer to remember
|
|
165
|
+
if (e instanceof RateLimited) t.unreachable(`${r.name} not validated this run (${e.message}) — retry later`)
|
|
166
|
+
else if (e instanceof ToolUnresolved) { t.unmeasured = true; t.note(`${r.name} skipped (${e.message})`) }
|
|
167
|
+
else t.fail(`${r.name} errored (${String(e).slice(0, 200)}) — network down, or a bug in this check`)
|
|
168
|
+
}
|
|
169
|
+
// AN ANSWER CACHES. THE ABSENCE OF ONE DOES NOT. That distinction, not
|
|
170
|
+
// pass-versus-fail, is what decides cacheability here, and a future
|
|
171
|
+
// reader is warned off "fixing" the asymmetry:
|
|
172
|
+
//
|
|
173
|
+
// measured negative a probe that RAN and came back bad ("the box is
|
|
174
|
+
// off", "the token answered 403", "five repos are
|
|
175
|
+
// uncatalogued") is a real answer about the world.
|
|
176
|
+
// Replaying it for its TTL is CORRECT: the world
|
|
177
|
+
// does not fix itself between two gate runs, and
|
|
178
|
+
// re-asking every 30 seconds spends a third-party
|
|
179
|
+
// API call to be told the same thing.
|
|
180
|
+
// unmeasured a probe that could not run at all ("registry
|
|
181
|
+
// unreachable", "no cred", "rate limited", tool
|
|
182
|
+
// missing) produced no answer. Caching it asserts
|
|
183
|
+
// a dead network as fact long after it recovered.
|
|
184
|
+
// `skip` lines and the unmeasured flag mark these.
|
|
185
|
+
//
|
|
186
|
+
// A measured negative caches ONLY for a check about the WORLD. A
|
|
187
|
+
// blocking check reports THIS REPO, where the whole point is that a
|
|
188
|
+
// human fixes it and the next run goes green: replaying its red for a
|
|
189
|
+
// TTL would be the cache lying about work already done.
|
|
190
|
+
const unmeasured = t.unmeasured || t.lines.some((l) => l.kind === 'skip')
|
|
191
|
+
const clean = !t.failed && !t.warned
|
|
192
|
+
const cacheable = r.ttlMs > 0 && !unmeasured && (clean || !r.blocking)
|
|
193
|
+
const lines = cacheable
|
|
194
|
+
? 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))
|
|
195
|
+
: t.lines
|
|
196
|
+
this.onResult?.(r.name, { lines, status: t.failed ? 'fail' : t.warned ? 'warn' : 'pass', cached: false })
|
|
197
|
+
return {
|
|
198
|
+
lines,
|
|
199
|
+
skip: null,
|
|
200
|
+
ran: true,
|
|
201
|
+
failed: t.failed,
|
|
202
|
+
store: cacheable && r.ttlMs > 0 ? { version: r.version, key, at: this.now(), lines } : null,
|
|
203
|
+
// an unmeasured run evicts whatever was stored: a later run must
|
|
204
|
+
// re-probe rather than replay an answer recorded before the outage
|
|
205
|
+
evict: r.ttlMs > 0 && !cacheable && entry != null,
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
121
209
|
async execute(): Promise<{ lines: Line[]; exitCode: number }> {
|
|
122
210
|
const cachePath = join(this.cacheDir, `${this.name}.json`)
|
|
123
211
|
let cache: CacheFile = {}
|
|
124
212
|
try { cache = JSON.parse(readFileSync(cachePath, 'utf8')) } catch {}
|
|
125
213
|
let cacheDirty = false
|
|
126
214
|
|
|
215
|
+
// CONCURRENT, bounded. Checks are independent by contract: each answers
|
|
216
|
+
// one question about the world or the repo and shares nothing with its
|
|
217
|
+
// neighbours, and most of the wall time is a network round trip or a
|
|
218
|
+
// cold `bun` start. In series, twenty checks cost the SUM; together
|
|
219
|
+
// they cost the slowest one. Measured on this repo: 18.4s to 6.0s.
|
|
220
|
+
// Order is preserved by collecting per runner and concatenating in
|
|
221
|
+
// declaration order, so the wire output is byte-identical to the
|
|
222
|
+
// sequential run and the frozen contract still holds.
|
|
223
|
+
const cap = Math.max(2, Math.min(8, navigator.hardwareConcurrency || 4))
|
|
224
|
+
const results = new Array<Awaited<ReturnType<CheckSuite['runOne']>>>(this.runners.length)
|
|
225
|
+
let next = 0
|
|
226
|
+
const worker = async (): Promise<void> => {
|
|
227
|
+
for (;;) {
|
|
228
|
+
const i = next++
|
|
229
|
+
if (i >= this.runners.length) return
|
|
230
|
+
results[i] = await this.runOne(this.runners[i]!, cache)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
await Promise.all(Array.from({ length: Math.min(cap, this.runners.length) }, () => worker()))
|
|
234
|
+
|
|
127
235
|
const out: Line[] = []
|
|
128
236
|
const skipped: string[] = []
|
|
129
237
|
let ran = 0
|
|
130
238
|
let anyFail = false
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const key = prep.cacheExtra ?? ''
|
|
141
|
-
const entry = cache[r.name]
|
|
142
|
-
const fresh = r.ttlMs > 0 && process.env[this.freshEnv] == null
|
|
143
|
-
&& entry != null && entry.version === r.version && entry.key === key
|
|
144
|
-
&& this.now() - entry.at < r.ttlMs && this.now() >= entry.at
|
|
145
|
-
if (fresh) {
|
|
146
|
-
// the stamp was baked in when the check RAN (below): a replay
|
|
147
|
-
// prints the stored lines VERBATIM, so the live run and every
|
|
148
|
-
// later replay render the same string, and the line changes
|
|
149
|
-
// only when a re-validation happens. Annotating replays but
|
|
150
|
-
// not live runs still diffed on every fresh-vs-cached
|
|
151
|
-
// alternation, so neither side annotates
|
|
152
|
-
out.push(...entry.lines)
|
|
153
|
-
this.onResult?.(r.name, { lines: entry.lines, status: 'pass', cached: true }) // only clean passes are cached
|
|
154
|
-
continue
|
|
155
|
-
}
|
|
156
|
-
const t = new CheckCtx(this.toolPaths)
|
|
157
|
-
try {
|
|
158
|
-
await r.logic(t)
|
|
159
|
-
} catch (e) {
|
|
160
|
-
if (e instanceof RateLimited) t.warn(`${r.name} not validated this run (${e.message}) — retry later`)
|
|
161
|
-
else if (e instanceof ToolUnresolved) t.note(`${r.name} skipped (${e.message})`)
|
|
162
|
-
else t.fail(`${r.name} errored (${String(e).slice(0, 200)}) — network down, or a bug in this check`)
|
|
163
|
-
}
|
|
164
|
-
// a skip line is an UNKNOWN, not a clean pass — replaying
|
|
165
|
-
// "registry unreachable" for a TTL asserts a dead network as
|
|
166
|
-
// fact long after it recovered
|
|
167
|
-
const cacheable = r.ttlMs > 0 && !t.failed && !t.warned && !t.lines.some((l) => l.kind === 'skip')
|
|
168
|
-
const lines = cacheable
|
|
169
|
-
? t.lines.map((l, i): Line => (i === 0 ? { ...l, text: `${l.text} (cached at ${fmtStamp(this.now())} until ${fmtStamp(this.now() + r.ttlMs)})` } : l))
|
|
170
|
-
: t.lines
|
|
171
|
-
out.push(...lines)
|
|
172
|
-
this.onResult?.(r.name, { lines, status: t.failed ? 'fail' : t.warned ? 'warn' : 'pass', cached: false })
|
|
173
|
-
if (t.failed) anyFail = true
|
|
174
|
-
if (r.ttlMs > 0) {
|
|
175
|
-
if (cacheable) { cache[r.name] = { version: r.version, key, at: this.now(), lines }; cacheDirty = true }
|
|
176
|
-
// a WARN/FAIL evicts the stale pass — a later run must re-probe,
|
|
177
|
-
// not replay an ok recorded before the breakage
|
|
178
|
-
else if (entry != null) { delete cache[r.name]; cacheDirty = true }
|
|
179
|
-
}
|
|
239
|
+
for (let i = 0; i < this.runners.length; i++) {
|
|
240
|
+
const r = results[i]!
|
|
241
|
+
const name = this.runners[i]!.name
|
|
242
|
+
out.push(...r.lines)
|
|
243
|
+
if (r.skip != null) skipped.push(r.skip)
|
|
244
|
+
if (r.ran) ran++
|
|
245
|
+
if (r.failed) anyFail = true
|
|
246
|
+
if (r.store != null) { cache[name] = r.store; cacheDirty = true }
|
|
247
|
+
else if (r.evict) { delete cache[name]; cacheDirty = true }
|
|
180
248
|
}
|
|
181
249
|
|
|
182
250
|
// contract: `skip:` lines ONLY when no check ran at all (whole probe is
|