rvlib-shipkit 0.14.0 → 0.17.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 +141 -0
- package/README.md +6 -22
- package/package.json +1 -1
- package/src/assets/kits/identity-kit.ts +1 -1
- package/src/assets/panels/geometry-rects.ts +3 -17
- package/src/assets/server/studio-server.ts +1 -1
- package/src/cli/cmd_Bump.ts +61 -0
- package/src/cli/cmd_Check.ts +2 -2
- package/src/cli/cmd_Ci.ts +10 -2
- package/src/cli/cmd_Commands.ts +126 -0
- package/src/cli/cmd_Hooks.ts +66 -0
- package/src/cli/cmd_Mv.ts +29 -6
- package/src/cli/cmd_Release.ts +41 -0
- package/src/cli/cmd_Releases.ts +42 -0
- package/src/cli/cmd_Rules.ts +20 -6
- package/src/cli/cmd_Start.ts +2 -2
- package/src/cli.ts +14 -1
- package/src/collections/posts.ts +1 -1
- package/src/collections/providers.ts +1 -1
- package/src/contracts/manifests/discord.contract.ts +1 -1
- package/src/contracts/manifests/itch.contract.ts +2 -4
- package/src/core/canonical-scripts.ts +9 -14
- package/src/core/check-suite.ts +1 -1
- package/src/core/discover-checks.ts +21 -6
- package/src/core/run-check.ts +69 -21
- package/src/core/run-ci.ts +78 -9
- package/src/core/run-fixers.ts +144 -0
- package/src/core/write-status.ts +14 -5
- package/src/hooks/commit-message.ts +42 -0
- package/src/hooks/leak-guard.ts +0 -0
- package/src/hooks/root-identity.ts +64 -0
- package/src/hooks/run-hooks.ts +149 -0
- package/src/init-repo.ts +10 -5
- package/src/reflect/prompt-corpus.ts +3 -4
- package/src/release/changelog.ts +70 -0
- package/src/release/git-facts.ts +133 -0
- package/src/release/run-bump.ts +436 -0
- package/src/release/run-release.ts +271 -0
- package/src/release/run-releases.ts +230 -0
- package/src/release/unit-gate.ts +67 -0
- package/src/release/units.ts +246 -0
- package/src/repo-config.ts +56 -2
- package/src/rules/SK001-package-json-shape.ts +39 -17
- package/src/rules/SK002-contracts-fulfillness.ts +1 -0
- package/src/rules/SK003-claude-md-shape.ts +2 -1
- package/src/rules/SK004-gitignore-policy.ts +4 -0
- package/src/rules/SK005-rvlib-versions.ts +22 -3
- package/src/rules/SK006-npm-publish-safety.ts +1 -0
- package/src/rules/SK007-github-visibility.ts +5 -1
- package/src/rules/SK008-reviewer-agent.ts +1 -0
- package/src/rules/SK009-commit-ai-tells.ts +66 -0
- package/src/rules/SK010-undeclared-release-unit.ts +125 -0
- package/src/rules/rule-registry.ts +26 -6
- package/src/rules/rule.ts +8 -1
- package/src/rules/run-fix.ts +15 -8
- package/src/serve/hub-app/col-card.tsx +2 -3
- package/src/serve/hub-app/hub-app.tsx +6 -8
- package/src/serve/hub-app/hub-st.ts +4 -7
- package/src/serve/hub-app/media-view.tsx +2 -2
- package/src/serve/run-serve.ts +4 -5
- package/src/serve/serve-hub.ts +2 -3
- package/src/shipkit.ts +9 -1
- package/src/tui/checks-panel.ts +1 -1
- package/src/tui/run-tui.ts +2 -3
- package/src/tui/tasks-panel.ts +10 -4
- package/src/tui/tui-store.ts +2 -2
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// shipkit release, the one release path, public or private. Thin clipanion
|
|
2
|
+
// surface; logic in release/run-release.ts.
|
|
3
|
+
import { Command, Option } from 'clipanion'
|
|
4
|
+
import { loadRepoConfig } from '../repo-config.ts'
|
|
5
|
+
import { runRelease } from '../release/run-release.ts'
|
|
6
|
+
|
|
7
|
+
export class ReleaseCommand extends Command {
|
|
8
|
+
static override paths = [['release']]
|
|
9
|
+
|
|
10
|
+
static override usage = Command.Usage({
|
|
11
|
+
category: 'Gate',
|
|
12
|
+
description: 'cut a release for ONE unit (default the repo root): ci gate → npm publish (non-private only) → git tag → push → GitHub release, notes from that unit CHANGELOG section. Resumable after a partial failure, which is why it takes no --all.',
|
|
13
|
+
examples: [
|
|
14
|
+
['rehearse — touches nothing', 'shipkit release --dry-run'],
|
|
15
|
+
['release the version in package.json', 'shipkit release'],
|
|
16
|
+
['finish a sub-unit bump stopped part way (ids: shipkit releases)', 'shipkit release rvlib-shipkit'],
|
|
17
|
+
['release with dirt elsewhere in the worktree', 'shipkit release --allow-dirty'],
|
|
18
|
+
],
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
unit = Option.String({ required: false })
|
|
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). A SUB-unit still refuses on dirt inside its own path' })
|
|
24
|
+
skipGate = Option.Boolean('--skip-gate', false, { description: 'skip the ci gate preflight — publishes unverified, use only to recover a stuck release' })
|
|
25
|
+
|
|
26
|
+
async execute(): Promise<number> {
|
|
27
|
+
try {
|
|
28
|
+
const config = await loadRepoConfig(process.cwd())
|
|
29
|
+
return await runRelease(process.cwd(), config, {
|
|
30
|
+
dryRun: this.dryRun,
|
|
31
|
+
allowDirty: this.allowDirty,
|
|
32
|
+
skipGate: this.skipGate,
|
|
33
|
+
unit: this.unit,
|
|
34
|
+
print: (l) => this.context.stdout.write(`${l}\n`),
|
|
35
|
+
})
|
|
36
|
+
} catch (e) {
|
|
37
|
+
this.context.stderr.write(`FAIL: ${e instanceof Error ? e.message : String(e)}\n`)
|
|
38
|
+
return 1
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// shipkit releases — every release unit and where it actually stands. Thin
|
|
2
|
+
// clipanion surface; logic in release/run-releases.ts.
|
|
3
|
+
import { Command, Option } from 'clipanion'
|
|
4
|
+
import { loadRepoConfig } from '../repo-config.ts'
|
|
5
|
+
import { collectReports, renderReports } from '../release/run-releases.ts'
|
|
6
|
+
|
|
7
|
+
export class ReleasesCommand extends Command {
|
|
8
|
+
static override paths = [['releases']]
|
|
9
|
+
|
|
10
|
+
static override usage = Command.Usage({
|
|
11
|
+
category: 'Gate',
|
|
12
|
+
description: 'every release unit and where it stands: version, npm, tag local and remote, GitHub release, drift. Reports, never gates (only an unresolvable config exits non-zero)',
|
|
13
|
+
examples: [
|
|
14
|
+
['what is published and what is not', 'shipkit releases'],
|
|
15
|
+
['filesystem + local git only, no network', 'shipkit releases --offline'],
|
|
16
|
+
['machine-readable', 'shipkit releases --json'],
|
|
17
|
+
],
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
json = Option.Boolean('--json', false, { description: 'emit { units: UnitReport[], errors: string[] } JSON' })
|
|
21
|
+
offline = Option.Boolean('--offline', false, { description: 'skip npm, the remote and gh — local facts only' })
|
|
22
|
+
|
|
23
|
+
async execute(): Promise<number> {
|
|
24
|
+
const root = process.cwd()
|
|
25
|
+
try {
|
|
26
|
+
const config = await loadRepoConfig(root)
|
|
27
|
+
const { reports, errors, remoteError } = await collectReports(root, config, { offline: this.offline })
|
|
28
|
+
if (this.json) {
|
|
29
|
+
this.context.stdout.write(`${JSON.stringify({ units: reports, errors }, null, 2)}\n`)
|
|
30
|
+
return errors.length > 0 ? 1 : 0
|
|
31
|
+
}
|
|
32
|
+
for (const l of renderReports(reports, { remoteError, offline: this.offline })) this.context.stdout.write(`${l}\n`)
|
|
33
|
+
// a unit nobody can describe is a repo bug, not a world fact: the
|
|
34
|
+
// listing still prints, and the exit code still says something broke
|
|
35
|
+
for (const e of errors) this.context.stderr.write(`FAIL: ${e}\n`)
|
|
36
|
+
return errors.length > 0 ? 1 : 0
|
|
37
|
+
} catch (e) {
|
|
38
|
+
this.context.stderr.write(`FAIL: ${e instanceof Error ? e.message : String(e)}\n`)
|
|
39
|
+
return 1
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/cli/cmd_Rules.ts
CHANGED
|
@@ -1,24 +1,36 @@
|
|
|
1
|
-
// shipkit rules
|
|
2
|
-
//
|
|
1
|
+
// shipkit rules, render the rule registry: shipkit's built-in SK rules
|
|
2
|
+
// plus whatever the repo registered through `repo.config.ts rules: []`.
|
|
3
|
+
// Agents read ids HERE (or --json), never guess or mint one: the registry
|
|
4
|
+
// is the single source, and a repo whose own invariants are absent from it
|
|
5
|
+
// is a repo whose agents cannot know what binds them.
|
|
3
6
|
import { Command, Option } from 'clipanion'
|
|
4
7
|
import { RULES } from '../rules/rule-registry.ts'
|
|
8
|
+
import { loadRepoConfig } from '../repo-config.ts'
|
|
5
9
|
|
|
6
10
|
export class RulesCommand extends Command {
|
|
7
11
|
static override paths = [['rules']]
|
|
8
12
|
|
|
9
13
|
static override usage = Command.Usage({
|
|
10
14
|
category: 'Health',
|
|
11
|
-
description: '
|
|
15
|
+
description: 'every rule that runs here: whether it BLOCKS or only warns, what it lints, the owning doc, and whether --fix can repair it. Built-in SK rules plus the repo\'s own.',
|
|
12
16
|
examples: [
|
|
13
17
|
['list rules', 'shipkit rules'],
|
|
14
18
|
['machine-readable', 'shipkit rules --json'],
|
|
15
19
|
],
|
|
16
20
|
})
|
|
17
21
|
|
|
18
|
-
json = Option.Boolean('--json', false, { description: 'machine-readable [{id, title, owner, fixable}]' })
|
|
22
|
+
json = Option.Boolean('--json', false, { description: 'machine-readable [{id, title, owner, blocking, fixable, source}]' })
|
|
19
23
|
|
|
20
24
|
async execute(): Promise<number> {
|
|
21
|
-
|
|
25
|
+
// a repo without repo.config.ts still gets the built-in catalog: this
|
|
26
|
+
// command must answer "what rules exist" from anywhere
|
|
27
|
+
const repoRules = await loadRepoConfig(process.cwd())
|
|
28
|
+
.then((c) => c.rules ?? [])
|
|
29
|
+
.catch(() => [])
|
|
30
|
+
const rows = [
|
|
31
|
+
...RULES.map((r) => ({ id: r.id, title: r.title, owner: r.owner, blocking: r.blocking, fixable: r.fix != null, source: 'shipkit' as const })),
|
|
32
|
+
...repoRules.map((r) => ({ id: r.id, title: r.title, owner: r.owner, blocking: r.blocking, fixable: r.fix != null, source: 'repo' as const })),
|
|
33
|
+
]
|
|
22
34
|
if (this.json) {
|
|
23
35
|
this.context.stdout.write(`${JSON.stringify(rows)}\n`)
|
|
24
36
|
return 0
|
|
@@ -26,7 +38,9 @@ export class RulesCommand extends Command {
|
|
|
26
38
|
// a catalog, not a health run — no wire prefixes (`ok:` would assert
|
|
27
39
|
// a passing check into anything that parses wire output)
|
|
28
40
|
for (const r of rows)
|
|
29
|
-
this.context.stdout.write(`${r.id} ${r.title} — ${r.owner}${r.fixable ? ' · fixable' : ''}\n`)
|
|
41
|
+
this.context.stdout.write(`${r.blocking ? 'BLOCKS ' : 'warns '}${r.id} ${r.title} — ${r.owner}${r.fixable ? ' · fixable' : ''}\n`)
|
|
42
|
+
if (repoRules.length > 0)
|
|
43
|
+
this.context.stdout.write(`\n${RULES.length} built-in · ${repoRules.length} repo-local (repo.config.ts rules)\n`)
|
|
30
44
|
return 0
|
|
31
45
|
}
|
|
32
46
|
}
|
package/src/cli/cmd_Start.ts
CHANGED
|
@@ -9,7 +9,7 @@ export class StartCommand extends Command {
|
|
|
9
9
|
|
|
10
10
|
static override usage = Command.Usage({
|
|
11
11
|
category: 'Setup',
|
|
12
|
-
description: 'ensure <root>/<name> exists, optionally with a CLAUDE.md include line
|
|
12
|
+
description: 'ensure <root>/<name> exists, optionally with a CLAUDE.md include line. Prints the dir on stdout so a shell alias can cd there',
|
|
13
13
|
examples: [
|
|
14
14
|
['start (or enter) a repo', 'shipkit start pong'],
|
|
15
15
|
['seed CLAUDE.md includes', "shipkit start pong --include '@~/wiki/index.md' --include '@./agent/index.md'"],
|
|
@@ -18,7 +18,7 @@ export class StartCommand extends Command {
|
|
|
18
18
|
|
|
19
19
|
name = Option.String()
|
|
20
20
|
root = Option.String('--root', { description: 'parent dir for repos (default ~/dev)' })
|
|
21
|
-
include = Option.Array('--include', { description: 'line CLAUDE.md must carry, repeatable, order kept
|
|
21
|
+
include = Option.Array('--include', { description: 'line CLAUDE.md must carry, repeatable, order kept. Omitted means only ensure the dir' })
|
|
22
22
|
alsoOk = Option.Array('--also-ok', { description: 'alternative lines satisfying the first --include — never double-add' })
|
|
23
23
|
skip = Option.Array('--skip', { description: 'repo names that never get the include' })
|
|
24
24
|
|
package/src/cli.ts
CHANGED
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
// shipkit — clipanion entry: one command per file in cli/ (cmd_<Name>.ts),
|
|
2
|
+
// shipkit — clipanion entry: one command FAMILY per file in cli/ (cmd_<Name>.ts),
|
|
3
3
|
// logic in plain modules the commands call. runExit sets the exit code
|
|
4
4
|
// without force-exiting, so serve commands keep the process alive.
|
|
5
5
|
import { readFileSync } from 'fs'
|
|
6
6
|
import { Builtins, Cli } from 'clipanion'
|
|
7
7
|
import { AskNewCommand, AsksCheckCommand, AsksCommand } from './cli/cmd_Asks.ts'
|
|
8
8
|
import { AssetsAddCommand, AssetsCheckCommand, AssetsFillCommand, AssetsGenCommand, AssetsIdentityCommand } from './cli/cmd_Assets.ts'
|
|
9
|
+
import { BumpCommand } from './cli/cmd_Bump.ts'
|
|
9
10
|
import { CheckCommand } from './cli/cmd_Check.ts'
|
|
11
|
+
import { CommandsCommand } from './cli/cmd_Commands.ts'
|
|
10
12
|
import { CiCommand } from './cli/cmd_Ci.ts'
|
|
11
13
|
import { ContractsCommand } from './cli/cmd_Contracts.ts'
|
|
14
|
+
import { HooksCommitMsgCommand, HooksInstallCommand, HooksListCommand, HooksPreCommitCommand } from './cli/cmd_Hooks.ts'
|
|
12
15
|
import { InitCommand } from './cli/cmd_Init.ts'
|
|
13
16
|
import { MvCommand } from './cli/cmd_Mv.ts'
|
|
14
17
|
import { NeedsCommand } from './cli/cmd_Needs.ts'
|
|
15
18
|
import { ReflectCommand } from './cli/cmd_Reflect.ts'
|
|
19
|
+
import { ReleaseCommand } from './cli/cmd_Release.ts'
|
|
20
|
+
import { ReleasesCommand } from './cli/cmd_Releases.ts'
|
|
16
21
|
import { RulesCommand } from './cli/cmd_Rules.ts'
|
|
17
22
|
import { ServeCommand } from './cli/cmd_Serve.ts'
|
|
18
23
|
import { StartCommand } from './cli/cmd_Start.ts'
|
|
@@ -32,6 +37,13 @@ const cli = new Cli({
|
|
|
32
37
|
cli.register(Builtins.HelpCommand)
|
|
33
38
|
cli.register(Builtins.VersionCommand)
|
|
34
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)
|
|
35
47
|
cli.register(MvCommand)
|
|
36
48
|
cli.register(CheckCommand)
|
|
37
49
|
cli.register(TuiCommand)
|
|
@@ -52,5 +64,6 @@ cli.register(StartCommand)
|
|
|
52
64
|
cli.register(NeedsCommand)
|
|
53
65
|
cli.register(ReflectCommand)
|
|
54
66
|
cli.register(RulesCommand)
|
|
67
|
+
cli.register(CommandsCommand)
|
|
55
68
|
|
|
56
69
|
cli.runExit(process.argv.slice(2))
|
package/src/collections/posts.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// variants are sibling files `<slug>.a.md`, `<slug>.b.md`; picking one flips
|
|
5
5
|
// it to picked and the others to rejected (files KEPT). Severities:
|
|
6
6
|
// any file missing `status:` → red (metadata with no inventable default)
|
|
7
|
-
// drafts awaiting a pick → yellow (
|
|
7
|
+
// drafts awaiting a pick → yellow (a pick is a taste act)
|
|
8
8
|
// picked but not posted → red (paste-from-elsewhere: post + URL proof)
|
|
9
9
|
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
|
10
10
|
import { join } from 'path'
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Behaviors preserved by name: per-source failure isolation (projectNeeds
|
|
4
4
|
// guards per collection), the identity self-heal before surfacing, and the
|
|
5
5
|
// asks READ path never minting asks/ on a repo without one. Dedup doctrine
|
|
6
|
-
//
|
|
6
|
+
// : dedup = acted-on once, NEVER hidden — an
|
|
7
7
|
// ask-covered missing cred still shows RED in secrets with a pointer to the
|
|
8
8
|
// ask; verbs and pings stay with the ask. Each wire line of STATUS.md lives
|
|
9
9
|
// in exactly ONE collection: cred probe lines in secrets, everything else
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// normative WHAT for .rv-discord/ — a project's public Discord server
|
|
2
|
-
// contract
|
|
2
|
+
// contract. Posts stay in .rv-social/.
|
|
3
3
|
import { defineContract, dirExists, md, png } from 'rvlib-shipkit/src/contracts/contract-types.ts'
|
|
4
4
|
|
|
5
5
|
export default defineContract({
|
|
@@ -17,16 +17,14 @@ export default defineContract({
|
|
|
17
17
|
// handoff (references/listing.template.md is the fill-in copy)
|
|
18
18
|
md('.rv-itch/listing.md', { mustContain: ['## Title', '## Short description or tagline', '## Classification', '## Genre', '## Tags', '## Visibility'] }),
|
|
19
19
|
// headings alone would pass the TEMPLATE copied in verbatim, and this
|
|
20
|
-
// file exists to be pasted with no decision left in it
|
|
21
|
-
// 2026-07-27: a reviewer proved `cp template listing.md` went green)
|
|
20
|
+
// file exists to be pasted with no decision left in it
|
|
22
21
|
custom('.rv-itch/listing.md answered', (env) => {
|
|
23
22
|
const p = join(env.root, '.rv-itch/listing.md')
|
|
24
23
|
if (!existsSync(p)) return []
|
|
25
24
|
const left = [...readFileSync(p, 'utf8').matchAll(/<[a-z][^<>\n]{2,60}>/g)].map((m) => m[0])
|
|
26
25
|
return left.length === 0 ? [] : [`.rv-itch/listing.md still holds ${left.length} unanswered placeholder(s): ${[...new Set(left)].slice(0, 3).join(' ')} — it is pasted into the store form as-is`]
|
|
27
26
|
}),
|
|
28
|
-
// itch DISPLAYS 630x500 and accepts larger —
|
|
29
|
-
// block a release over a better cover (audit 2026-07-27)
|
|
27
|
+
// itch DISPLAYS 630x500 and accepts larger — atLeast, never exact-size
|
|
30
28
|
png('.rv-itch/cover.png', 630, 500, { atLeast: true }),
|
|
31
29
|
glob('.rv-itch/screenshots/*.png', { min: 3 }),
|
|
32
30
|
],
|
|
@@ -1,21 +1,19 @@
|
|
|
1
1
|
// The canonical package.json script bodies for a shipkit repo — vocabulary
|
|
2
2
|
// owned by meta/rv-conventions/package-json.md. ONE source: `shipkit init`
|
|
3
3
|
// scaffolds from it and rule SK001's autofix repairs from it.
|
|
4
|
+
//
|
|
5
|
+
// a script that only forwards to a shipkit verb is NOT canon: the verb is the
|
|
6
|
+
// vocabulary, and the alias is one more name to keep in sync. only a body
|
|
7
|
+
// shipkit cannot express itself earns a script.
|
|
4
8
|
export type ScriptStyle = {
|
|
5
9
|
/** in-repo-source consumers: path to shipkit's cli.ts — bodies become `bun <path> <cmd>` */
|
|
6
10
|
bin?: string
|
|
7
11
|
}
|
|
8
12
|
|
|
9
13
|
export function canonicalBodies(opts: ScriptStyle = {}): Record<string, string> {
|
|
10
|
-
const run = (cmd: string): string => (opts.bin != null ? `bun ${opts.bin} ${cmd}` : `shipkit ${cmd}`)
|
|
11
14
|
return {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
contracts: run('contracts'),
|
|
15
|
-
tui: run('tui'),
|
|
16
|
-
mv: run('mv'),
|
|
17
|
-
// hub runs in WATCH mode (house default 2026-07-23): lib/config edits
|
|
18
|
-
// restart the server live; the npm shim can't watch, the source path can
|
|
15
|
+
// hub runs in WATCH mode: lib/config edits restart the server live.
|
|
16
|
+
// `shipkit serve` alone cannot watch, so this one is not an alias
|
|
19
17
|
hub: `bun --watch ${opts.bin ?? 'node_modules/rvlib-shipkit/src/cli.ts'} serve`,
|
|
20
18
|
// baseline `test` is universal — lint/fmt bodies are stack-specific,
|
|
21
19
|
// the scaffold pages own those
|
|
@@ -24,14 +22,11 @@ export function canonicalBodies(opts: ScriptStyle = {}): Record<string, string>
|
|
|
24
22
|
}
|
|
25
23
|
|
|
26
24
|
// conditional canon (bodies stated by the vocabulary page) — keyed by the
|
|
27
|
-
// SK001 requirement name;
|
|
28
|
-
//
|
|
29
|
-
export function conditionalBodies(
|
|
30
|
-
const run = (cmd: string): string => (opts.bin != null ? `bun ${opts.bin} ${cmd}` : `shipkit ${cmd}`)
|
|
25
|
+
// SK001 requirement name; a requirement with no canonical body (comfy:up) is
|
|
26
|
+
// deliberately absent: a fixer must not invent one
|
|
27
|
+
export function conditionalBodies(_opts: ScriptStyle = {}): Record<string, string> {
|
|
31
28
|
return {
|
|
32
29
|
'up': './deploy.sh',
|
|
33
|
-
'asks': run('asks'),
|
|
34
|
-
'assets:identity': run('assets identity'),
|
|
35
30
|
}
|
|
36
31
|
}
|
|
37
32
|
|
package/src/core/check-suite.ts
CHANGED
|
@@ -163,7 +163,7 @@ export class CheckSuite {
|
|
|
163
163
|
if (r.ttlMs > 0) {
|
|
164
164
|
// a skip line is an UNKNOWN, not a clean pass — replaying
|
|
165
165
|
// "registry unreachable" for a TTL asserts a dead network as
|
|
166
|
-
// fact long after it recovered
|
|
166
|
+
// fact long after it recovered
|
|
167
167
|
if (!t.failed && !t.warned && !t.lines.some((l) => l.kind === 'skip')) { cache[r.name] = { version: r.version, key, at: this.now(), lines: t.lines }; cacheDirty = true }
|
|
168
168
|
// a WARN/FAIL evicts the stale pass — a later run must re-probe,
|
|
169
169
|
// not replay an ok recorded before the breakage
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
// *.check.ts auto-discovery — THE way
|
|
2
|
-
//
|
|
3
|
-
// anywhere in the repo and `shipkit check` runs it as a
|
|
1
|
+
// *.check.ts auto-discovery — THE way a repo registers its OWN checks
|
|
2
|
+
// (built-in SK rules come from rules/rule-registry.ts): drop a
|
|
3
|
+
// `<name>.check.ts` anywhere in the repo and `shipkit check` runs it as a
|
|
4
|
+
// subprocess — wire
|
|
4
5
|
// output parsed back, exit code is truth. Skipped dirs: node_modules, dist,
|
|
5
6
|
// tmp, references (templates for other repos), and every dot-dir (.git,
|
|
6
7
|
// caches, .rv-* contract folders).
|
|
@@ -18,8 +19,16 @@ export type DiscoveredCheck = {
|
|
|
18
19
|
name: string
|
|
19
20
|
/** file runs its own CheckSuite (own TTL cache) — the orchestrator must not double-cache it */
|
|
20
21
|
selfCached: boolean
|
|
22
|
+
/** `export const blocking = true | false`, read statically. null = the file
|
|
23
|
+
* never said, which is an error: a check that silently does not block is
|
|
24
|
+
* the failure nobody sees. */
|
|
25
|
+
blocking: boolean | null
|
|
21
26
|
}
|
|
22
27
|
|
|
28
|
+
// read without importing: checks run as subprocesses, and discovery must stay
|
|
29
|
+
// pure-fs. `export const blocking = true` (or false), nothing cleverer.
|
|
30
|
+
const BLOCKING_RE = /^\s*export\s+const\s+blocking\s*(?::\s*boolean\s*)?=\s*(true|false)\s*$/m
|
|
31
|
+
|
|
23
32
|
export function discoverChecks(root: string): DiscoveredCheck[] {
|
|
24
33
|
const files: string[] = []
|
|
25
34
|
const walk = (dir: string): void => {
|
|
@@ -39,10 +48,16 @@ export function discoverChecks(root: string): DiscoveredCheck[] {
|
|
|
39
48
|
return files.map((f) => {
|
|
40
49
|
const base = baseOf(f)
|
|
41
50
|
const name = (seen.get(base) ?? 0) > 1 ? relative(root, f).slice(0, -SUFFIX.length) : base
|
|
42
|
-
// a check built on CheckSuite/
|
|
51
|
+
// a check built on CheckSuite/PageValidator replays from its OWN
|
|
43
52
|
// TTL cache — caching it again up here would double the staleness
|
|
44
53
|
let selfCached = false
|
|
45
|
-
|
|
46
|
-
|
|
54
|
+
let blocking: boolean | null = null
|
|
55
|
+
try {
|
|
56
|
+
const src = readFileSync(f, 'utf8')
|
|
57
|
+
selfCached = /PageValidator|CheckSuite/.test(src)
|
|
58
|
+
const m = src.match(BLOCKING_RE)
|
|
59
|
+
blocking = m == null ? null : m[1] === 'true'
|
|
60
|
+
} catch {}
|
|
61
|
+
return { path: f, name, selfCached, blocking }
|
|
47
62
|
})
|
|
48
63
|
}
|
package/src/core/run-check.ts
CHANGED
|
@@ -6,13 +6,14 @@
|
|
|
6
6
|
// layers (SHIPKIT_FRESH=1 + RV_CHECK_FRESH=1 travel to children).
|
|
7
7
|
import { join, relative } from 'path'
|
|
8
8
|
import { CheckSuite, type CheckOutcome } from './check-suite.ts'
|
|
9
|
+
import { CheckCtx } from './check-ctx.ts'
|
|
9
10
|
import { discoverChecks } from './discover-checks.ts'
|
|
10
11
|
import { spawnWithTimeout } from './spawn-timeout.ts'
|
|
11
|
-
import { loadRepoConfig } from '../repo-config.ts'
|
|
12
|
+
import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
|
|
12
13
|
import { reportToMotherhome } from './motherhome.ts'
|
|
13
14
|
import { RULES } from '../rules/rule-registry.ts'
|
|
14
15
|
import { runRuleLint } from '../rules/rule.ts'
|
|
15
|
-
import { formatLine, parseWireOutput } from './wire.ts'
|
|
16
|
+
import { formatLine, parseWireOutput, type Line } from './wire.ts'
|
|
16
17
|
import { writeStatus } from './write-status.ts'
|
|
17
18
|
|
|
18
19
|
// orchestrator cache for checks WITHOUT their own suite — short on purpose:
|
|
@@ -31,11 +32,27 @@ export type CheckOpts = {
|
|
|
31
32
|
print?: (line: string) => void
|
|
32
33
|
/** per-check completion tap — the TUI checks panel streams results in */
|
|
33
34
|
onResult?: (name: string, r: CheckOutcome) => void
|
|
35
|
+
/** the already-resolved config, same contract as runCi: the caller that
|
|
36
|
+
* decides severity and the engine that runs the rules must read the same
|
|
37
|
+
* object, or a blocking rule failure silently reclassifies as a probe */
|
|
38
|
+
config?: RepoConfig
|
|
34
39
|
}
|
|
35
40
|
|
|
36
|
-
export
|
|
37
|
-
|
|
38
|
-
|
|
41
|
+
export type CheckRun = {
|
|
42
|
+
/** wire lines, with non-blocking failures already downgraded to warnings */
|
|
43
|
+
lines: Line[]
|
|
44
|
+
/** a BLOCKING check failed */
|
|
45
|
+
blocked: boolean
|
|
46
|
+
/** names of non-blocking checks that failed — reported, never fatal */
|
|
47
|
+
advisoryFailures: string[]
|
|
48
|
+
/** checks that never declared `blocking` — always fatal */
|
|
49
|
+
undeclared: string[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** run every check and classify by what it declared. This is the whole engine;
|
|
53
|
+
* runCi wraps it with the task DAG, and nothing else decides severity. */
|
|
54
|
+
export async function collectChecks(root: string, o: CheckOpts = {}): Promise<CheckRun> {
|
|
55
|
+
const c = o.config ?? (await loadRepoConfig(root))
|
|
39
56
|
// env carries the flags to child checks — restored after the run so a
|
|
40
57
|
// long-lived caller (doctor, TUI, hub) is never poisoned by one
|
|
41
58
|
// fresh/frozen invocation
|
|
@@ -44,15 +61,29 @@ export async function runCheck(root: string, o: CheckOpts = {}): Promise<number>
|
|
|
44
61
|
if (o.fresh === true) process.env.SHIPKIT_FRESH = '1'
|
|
45
62
|
if (o.frozen === true) process.env.SHIPKIT_FROZEN = '1'
|
|
46
63
|
const suite = new CheckSuite({ name: c.name, cacheDir: join(root, 'tmp/.shipkit-cache'), onResult: o.onResult })
|
|
47
|
-
// built-in SK rules (rules/rule-registry.ts)
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
64
|
+
// built-in SK rules (rules/rule-registry.ts) THEN the repo's own
|
|
65
|
+
// (repo.config rules: []), id-tagged, crash-safe, fix-pointer floor
|
|
66
|
+
// (rules/rule.ts runRuleLint owns the semantics). `shipkit ci` runs this
|
|
67
|
+
// same engine, so a blocking rule blocks a commit as well as a doctor run
|
|
68
|
+
const undeclared: string[] = []
|
|
69
|
+
const advisoryFailures: string[] = []
|
|
70
|
+
// a non-blocking failure becomes a WARNING here, at the source. printing
|
|
71
|
+
// FAIL and exiting 0 would be the tool lying about its own severity, and
|
|
72
|
+
// demoting in the check means the suite's exit code already means exactly
|
|
73
|
+
// "something that blocks you failed".
|
|
74
|
+
const demote = (t: CheckCtx, blocking: boolean, name: string): void => {
|
|
75
|
+
if (blocking || !t.failed) return
|
|
76
|
+
if (!advisoryFailures.includes(name)) advisoryFailures.push(name)
|
|
77
|
+
t.lines = t.lines.map((l) => (l.kind === 'fail' ? { ...l, kind: 'warn' as const } : l.kind === 'fix-fail' ? { ...l, kind: 'fix-warn' as const } : l))
|
|
78
|
+
t.failed = false
|
|
79
|
+
t.warned = true
|
|
80
|
+
}
|
|
81
|
+
for (const rule of [...RULES, ...(c.rules ?? [])])
|
|
51
82
|
suite.addCheck({
|
|
52
83
|
name: rule.title,
|
|
53
84
|
remainValidFor: rule.ttl,
|
|
54
85
|
cacheExtra: rule.cacheKey != null ? () => rule.cacheKey!({ root, config: c }) : undefined,
|
|
55
|
-
logic: (t) => runRuleLint(rule, t, { root, config: c }),
|
|
86
|
+
logic: async (t) => { await runRuleLint(rule, t, { root, config: c }); demote(t, rule.blocking, rule.title) },
|
|
56
87
|
})
|
|
57
88
|
const childEnv: Record<string, string | undefined> = {
|
|
58
89
|
...process.env,
|
|
@@ -60,6 +91,10 @@ export async function runCheck(root: string, o: CheckOpts = {}): Promise<number>
|
|
|
60
91
|
}
|
|
61
92
|
for (const d of discoverChecks(root)) {
|
|
62
93
|
const rel = relative(root, d.path)
|
|
94
|
+
// file wins, then the repo default, then nothing: an unclassified
|
|
95
|
+
// check is an error, never a guess
|
|
96
|
+
const blocking = d.blocking ?? c.checkDefaults?.blocking
|
|
97
|
+
if (blocking == null) undeclared.push(rel)
|
|
63
98
|
suite.addCheck({
|
|
64
99
|
name: d.name,
|
|
65
100
|
remainValidFor: d.selfCached ? undefined : DISCOVERED_TTL,
|
|
@@ -72,22 +107,35 @@ export async function runCheck(root: string, o: CheckOpts = {}): Promise<number>
|
|
|
72
107
|
// (crash, non-wire output, timeout) must still fail loud
|
|
73
108
|
if (r.code !== 0 && !t.failed)
|
|
74
109
|
t.fail(`${d.name} exited ${r.code}${r.timedOut ? ` (timeout after ${CHECK_TIMEOUT_MS}ms)` : ''} — ${r.out.trim().split('\n').at(-1)?.slice(0, 160) ?? ''}`, `bun ${rel}`)
|
|
110
|
+
demote(t, blocking ?? true, d.name)
|
|
75
111
|
},
|
|
76
112
|
})
|
|
77
113
|
}
|
|
78
114
|
const r = await suite.execute()
|
|
79
115
|
if (prevFresh == null) delete process.env.SHIPKIT_FRESH; else process.env.SHIPKIT_FRESH = prevFresh
|
|
80
116
|
if (prevFrozen == null) delete process.env.SHIPKIT_FROZEN; else process.env.SHIPKIT_FROZEN = prevFrozen
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
117
|
+
reportToMotherhome(root, c)
|
|
118
|
+
return { lines: r.lines, blocked: r.exitCode !== 0, advisoryFailures, undeclared }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** `shipkit check` as a standalone command: the same engine, printed. */
|
|
122
|
+
export async function runCheck(root: string, o: CheckOpts = {}): Promise<number> {
|
|
123
|
+
const print = o.print ?? ((l: string) => console.log(l))
|
|
124
|
+
const c = o.config ?? (await loadRepoConfig(root))
|
|
125
|
+
const run = await collectChecks(root, { ...o, config: c })
|
|
126
|
+
const code = run.blocked || run.undeclared.length > 0 ? 1 : 0
|
|
127
|
+
if (o.json === true) { print(JSON.stringify(run)); return code }
|
|
88
128
|
const color = process.stdout.isTTY && process.env.NO_COLOR == null
|
|
89
|
-
for (const l of
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
129
|
+
for (const l of run.lines) print(formatLine(l, color))
|
|
130
|
+
for (const u of run.undeclared) {
|
|
131
|
+
print(`FAIL: ${u} does not declare whether it blocks`)
|
|
132
|
+
print(` fix: add \`export const blocking = true\` (about this repo) or \`false\` (about the world) to that file`)
|
|
133
|
+
}
|
|
134
|
+
if (o.status !== false) {
|
|
135
|
+
// the producing command is named honestly: a checks-only run must not
|
|
136
|
+
// overwrite the gate verdict with a green nobody gated
|
|
137
|
+
const st = writeStatus(root, { name: c.name, command: 'shipkit check', lines: run.lines.map((l) => formatLine(l, false)), exitCode: code })
|
|
138
|
+
if (!st.written) print(`WARN ${st.warn}`)
|
|
139
|
+
}
|
|
140
|
+
return code
|
|
93
141
|
}
|
package/src/core/run-ci.ts
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
|
-
// the ci gate — floor + contracts + repo tasks through the DAG runner
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// atomic + lock) unless
|
|
1
|
+
// the ci gate — floor + contracts + repo tasks through the DAG runner, THEN
|
|
2
|
+
// every discovered check (core/run-check.ts owns severity). Plain module
|
|
3
|
+
// behind cmd_Ci (logic stays out of command classes) so tests can run the
|
|
4
|
+
// gate in-process. Returns the exit code; json prints the structured summary.
|
|
5
|
+
// Every run refreshes STATUS.md (write-status.ts — atomic + lock) unless
|
|
6
|
+
// status:false, and the fix pass plus some checks WRITE, so a run is not
|
|
7
|
+
// read-only unless frozen is set and --no-fix is passed.
|
|
6
8
|
import { join } from 'path'
|
|
7
|
-
import { allTasks, loadRepoConfig } from '../repo-config.ts'
|
|
9
|
+
import { allTasks, loadRepoConfig, type RepoConfig } from '../repo-config.ts'
|
|
8
10
|
import { reportToMotherhome } from './motherhome.ts'
|
|
11
|
+
import { collectChecks } from './run-check.ts'
|
|
12
|
+
import { runFixPass } from './run-fixers.ts'
|
|
13
|
+
import { formatLine } from './wire.ts'
|
|
9
14
|
import { runTasks, type TaskResult } from './task-runner.ts'
|
|
10
15
|
import { writeStatus } from './write-status.ts'
|
|
11
16
|
|
|
@@ -17,17 +22,60 @@ export type CiOpts = {
|
|
|
17
22
|
onlyIds?: string[]
|
|
18
23
|
/** refresh STATUS.md after the run (default true — O4: every repo gets it) */
|
|
19
24
|
status?: boolean
|
|
25
|
+
/** repair the mechanical slots before gating. default on locally, verify
|
|
26
|
+
* mode under CI. core/run-fixers.ts owns the scope. */
|
|
27
|
+
fix?: boolean
|
|
28
|
+
/** also apply the structural SK rule fixers during the fix pass */
|
|
29
|
+
fixRules?: boolean
|
|
30
|
+
/** bust every check's TTL cache. A release gates with this on: a blocking
|
|
31
|
+
* rule replaying a clean pass from hours ago is not a gate. */
|
|
32
|
+
fresh?: boolean
|
|
33
|
+
/** report-only for self-maintaining rules and checks (SHIPKIT_FROZEN
|
|
34
|
+
* travels to child checks). The release path passes it: a rule that
|
|
35
|
+
* rewrites package.json between the gate and `npm publish` packs a tree
|
|
36
|
+
* that exists in no commit. */
|
|
37
|
+
frozen?: boolean
|
|
38
|
+
/** treat a NON-blocking check failure as fatal too. `release.requireHealthy`
|
|
39
|
+
* is the only caller: a repo that wants a missing cred to stop a release
|
|
40
|
+
* asks for it, and every other run keeps the declared severity. */
|
|
41
|
+
failOnAdvisory?: boolean
|
|
42
|
+
/** run the tasks only. The TUI owns this: it renders the checks in its own
|
|
43
|
+
* panel, and running the suite twice per refresh proves nothing twice. */
|
|
44
|
+
skipChecks?: boolean
|
|
20
45
|
/** line sink — commands pass their context stream; defaults to console */
|
|
21
46
|
print?: (line: string) => void
|
|
22
47
|
/** per-task completion tap — the TUI tasks panel streams results in */
|
|
23
48
|
onTaskFinish?: (r: TaskResult) => void
|
|
49
|
+
/** the already-resolved config. A caller that holds one MUST pass it: two
|
|
50
|
+
* loads of repo.config.ts can disagree, and the release path classifies
|
|
51
|
+
* rule-vs-probe severity from the config it holds. */
|
|
52
|
+
config?: RepoConfig
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** what STATUS.md may claim about this run. `--with` ADDS a task, so it stays
|
|
56
|
+
* the full gate; `--only`, `--skip` and skipChecks each remove coverage, and
|
|
57
|
+
* a narrowed run that stamps the full gate's verdict is a green nobody
|
|
58
|
+
* measured. The label names the invocation either way. */
|
|
59
|
+
function ciScope(o: CiOpts): { command: string; fullGate: boolean } {
|
|
60
|
+
const narrowing = [
|
|
61
|
+
...(o.onlyIds ?? []).map((i) => `--only ${i}`),
|
|
62
|
+
...(o.skipGroups ?? []).map((g) => `--skip ${g}`),
|
|
63
|
+
...(o.skipChecks === true ? ['(tasks only)'] : []),
|
|
64
|
+
]
|
|
65
|
+
const parts = [...narrowing, ...(o.withIds ?? []).map((i) => `--with ${i}`)]
|
|
66
|
+
return { command: parts.length === 0 ? 'shipkit ci' : `shipkit ci ${parts.join(' ')}`, fullGate: narrowing.length === 0 }
|
|
24
67
|
}
|
|
25
68
|
|
|
26
69
|
export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
|
|
27
70
|
const print = o.print ?? ((l: string) => console.log(l))
|
|
28
71
|
const collected: string[] = []
|
|
29
72
|
const sink = (l: string): void => { collected.push(l); print(l) }
|
|
30
|
-
const c = await loadRepoConfig(root)
|
|
73
|
+
const c = o.config ?? (await loadRepoConfig(root))
|
|
74
|
+
// mechanical failures are not information, so they never cost a round trip
|
|
75
|
+
let fixFailed = false
|
|
76
|
+
if (o.fix !== false)
|
|
77
|
+
fixFailed = (await runFixPass(root, c, { rules: o.fixRules !== false, print: sink })).failed
|
|
78
|
+
else sink('skip: fix pass (--no-fix)')
|
|
31
79
|
const { tasks, disabled, unfilled } = allTasks(c, root)
|
|
32
80
|
for (const d of disabled) sink(`skip: ${d} (skipped-by-config)`)
|
|
33
81
|
// a floor slot nothing could fill is VISIBLE — a thin gate must never
|
|
@@ -44,12 +92,33 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
44
92
|
print: sink,
|
|
45
93
|
onTaskFinish: o.onTaskFinish,
|
|
46
94
|
})
|
|
47
|
-
|
|
95
|
+
// the checks, in the SAME command. there is no second surface: a check
|
|
96
|
+
// declares whether it blocks, and that is the only question about it.
|
|
97
|
+
const checks = o.skipChecks === true
|
|
98
|
+
? { lines: [], blocked: false, advisoryFailures: [], undeclared: [] }
|
|
99
|
+
: await collectChecks(root, { status: false, config: c, frozen: o.frozen, fresh: o.fresh })
|
|
100
|
+
const color = process.stdout.isTTY && process.env.NO_COLOR == null
|
|
101
|
+
for (const l of checks.lines) { collected.push(formatLine(l, false)); print(formatLine(l, color)) }
|
|
102
|
+
for (const u of checks.undeclared) {
|
|
103
|
+
sink(`FAIL: ${u} does not declare whether it blocks`)
|
|
104
|
+
sink(` fix: add \`export const blocking = true\` (about this repo) or \`false\` (about the world) to that file`)
|
|
105
|
+
}
|
|
106
|
+
let advisoryFatal = false
|
|
107
|
+
if (checks.advisoryFailures.length > 0) {
|
|
108
|
+
if (o.failOnAdvisory === true) {
|
|
109
|
+
advisoryFatal = true
|
|
110
|
+
sink(`FAIL: ${checks.advisoryFailures.length} non-blocking check(s) failing and release.requireHealthy is on: ${checks.advisoryFailures.join(', ')}`)
|
|
111
|
+
sink(' fix: shipkit check')
|
|
112
|
+
} else sink(`WARN ${checks.advisoryFailures.length} non-blocking check(s) failing, about the world rather than this repo: ${checks.advisoryFailures.join(', ')}`)
|
|
113
|
+
}
|
|
114
|
+
const code = summary.ok && !fixFailed && !checks.blocked && !advisoryFatal && checks.undeclared.length === 0 ? 0 : 1
|
|
48
115
|
// motherhome row (docsBase wiki on this disk) — never affects the gate
|
|
49
116
|
const mh = reportToMotherhome(root, c)
|
|
50
117
|
if (mh.note != null) print(mh.note)
|
|
51
118
|
if (o.status !== false) {
|
|
52
|
-
|
|
119
|
+
// scope is named in the file: ci proves the gate, not repo health, and
|
|
120
|
+
// a bare GREEN here would claim clear-to-ship on a repo with red checks
|
|
121
|
+
const st = writeStatus(root, { name: c.name, ...ciScope(o), lines: collected, exitCode: code })
|
|
53
122
|
if (!st.written) print(`WARN ${st.warn}`)
|
|
54
123
|
}
|
|
55
124
|
if (o.json === true)
|