rvlib-shipkit 0.13.0 → 0.16.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 +163 -0
- package/README.md +6 -22
- package/package.json +2 -2
- 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 +52 -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/contract-types.ts +5 -2
- package/src/contracts/contracts-engine.ts +13 -3
- package/src/contracts/manifests/discord.contract.ts +2 -2
- package/src/contracts/manifests/identity.contract.ts +1 -1
- package/src/contracts/manifests/itch.contract.ts +17 -2
- 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 +75 -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/run-hooks.ts +131 -0
- package/src/init-repo.ts +13 -5
- package/src/reflect/prompt-corpus.ts +3 -4
- package/src/release/changelog.ts +53 -0
- package/src/release/git-facts.ts +133 -0
- package/src/release/run-bump.ts +419 -0
- package/src/release/run-release.ts +267 -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 +66 -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 +1 -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 +99 -0
- package/src/rules/SK009-commit-ai-tells.ts +66 -0
- package/src/rules/SK010-undeclared-release-unit.ts +114 -0
- package/src/rules/rule-registry.ts +28 -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
|
|
@@ -6,7 +6,10 @@
|
|
|
6
6
|
|
|
7
7
|
export type RuleOpts = { warnOnly?: boolean }
|
|
8
8
|
export type FileRule = RuleOpts & { kind: 'file'; path: string; nonEmpty: boolean }
|
|
9
|
-
|
|
9
|
+
/** `atLeast`: the venue's numbers are a MINIMUM at a fixed aspect ratio (itch
|
|
10
|
+
* displays a 630x500 cover but accepts larger, and a 2x asset is better) —
|
|
11
|
+
* without it an exact-size rule blocks the release over a better file */
|
|
12
|
+
export type PngRule = RuleOpts & { kind: 'png'; path: string; width: number; height: number; atLeast?: boolean }
|
|
10
13
|
export type GlobRule = RuleOpts & { kind: 'glob'; pattern: string; min: number; width?: number; height?: number }
|
|
11
14
|
export type MdRule = RuleOpts & { kind: 'md'; path: string; mustContain: string[] }
|
|
12
15
|
export type DirRule = RuleOpts & { kind: 'dir'; path: string }
|
|
@@ -41,7 +44,7 @@ export type ContractManifest = {
|
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
export const file = (path: string, p?: RuleOpts & { nonEmpty?: boolean }): FileRule => ({ kind: 'file', path, nonEmpty: p?.nonEmpty ?? true, warnOnly: p?.warnOnly })
|
|
44
|
-
export const png = (path: string, width: number, height: number, p?: RuleOpts): PngRule => ({ kind: 'png', path, width, height, warnOnly: p?.warnOnly })
|
|
47
|
+
export const png = (path: string, width: number, height: number, p?: RuleOpts & { atLeast?: boolean }): PngRule => ({ kind: 'png', path, width, height, atLeast: p?.atLeast, warnOnly: p?.warnOnly })
|
|
45
48
|
export const glob = (pattern: string, p: RuleOpts & { min: number; width?: number; height?: number }): GlobRule => ({ kind: 'glob', pattern, min: p.min, width: p.width, height: p.height, warnOnly: p.warnOnly })
|
|
46
49
|
export const md = (path: string, p?: RuleOpts & { mustContain?: string[] }): MdRule => ({ kind: 'md', path, mustContain: p?.mustContain ?? [], warnOnly: p?.warnOnly })
|
|
47
50
|
export const dir = (path: string, p?: RuleOpts): DirRule => ({ kind: 'dir', path, warnOnly: p?.warnOnly })
|
|
@@ -57,11 +57,21 @@ async function checkRule(rule: ArtifactRule, env: ContractEnv): Promise<string[]
|
|
|
57
57
|
}
|
|
58
58
|
case 'png': {
|
|
59
59
|
const p = abs(env.root, rule.path)
|
|
60
|
-
|
|
60
|
+
const need = rule.atLeast === true ? `>= ${rule.width}x${rule.height} at ${rule.width}:${rule.height}` : `${rule.width}x${rule.height}`
|
|
61
|
+
if (!existsSync(p)) { problems.push(`${rule.path} missing — need png ${need}`); break }
|
|
61
62
|
const dims = pngDims(p)
|
|
62
63
|
if (dims == null) problems.push(`${rule.path} is not a readable png`)
|
|
63
|
-
else if (
|
|
64
|
-
problems.push(`${rule.path} is ${dims.width}x${dims.height} — need ${
|
|
64
|
+
else if (rule.atLeast !== true) {
|
|
65
|
+
if (dims.width !== rule.width || dims.height !== rule.height) problems.push(`${rule.path} is ${dims.width}x${dims.height} — need ${need}`)
|
|
66
|
+
} else {
|
|
67
|
+
const want = rule.width / rule.height
|
|
68
|
+
// RELATIVE tolerance: an absolute one is 0.8% at 630:500 and
|
|
69
|
+
// 3% at a tall ratio, i.e. a different rule per venue
|
|
70
|
+
const ratioOff = Math.abs(dims.width / dims.height - want) / want > 0.01
|
|
71
|
+
const tooSmall = dims.width < rule.width || dims.height < rule.height
|
|
72
|
+
if (tooSmall || ratioOff)
|
|
73
|
+
problems.push(`${rule.path} is ${dims.width}x${dims.height} — need ${need}${ratioOff ? ' (the ratio is off; a bigger asset at the same ratio is fine)' : ' (bigger is fine, smaller is not)'}`)
|
|
74
|
+
}
|
|
65
75
|
break
|
|
66
76
|
}
|
|
67
77
|
case 'glob': {
|
|
@@ -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({
|
|
@@ -10,6 +10,6 @@ export default defineContract({
|
|
|
10
10
|
detect: [dirExists('.rv-discord')],
|
|
11
11
|
artifacts: [
|
|
12
12
|
md('.rv-discord/server.md', { mustContain: ['invite'] }),
|
|
13
|
-
png('.rv-discord/icon.png', 512, 512),
|
|
13
|
+
png('.rv-discord/icon.png', 512, 512, { atLeast: true }),
|
|
14
14
|
],
|
|
15
15
|
})
|
|
@@ -13,7 +13,7 @@ export default defineContract({
|
|
|
13
13
|
artifacts: [
|
|
14
14
|
file('.rv-identity/brand.ts'),
|
|
15
15
|
png('public/favicon.png', 512, 512),
|
|
16
|
-
png('public/og.png', 1200, 630),
|
|
16
|
+
png('public/og.png', 1200, 630, { atLeast: true }),
|
|
17
17
|
glob('public/logo.*', { min: 1 }),
|
|
18
18
|
],
|
|
19
19
|
})
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// normative WHAT for .rv-itch/ — the WHY + workflow live in the sibling page.
|
|
2
|
-
import { defineContract, dirExists, fileContains, glob, md, png, script } from 'rvlib-shipkit/src/contracts/contract-types.ts'
|
|
2
|
+
import { custom, defineContract, dirExists, fileContains, glob, md, png, script } from 'rvlib-shipkit/src/contracts/contract-types.ts'
|
|
3
|
+
import { existsSync, readFileSync } from 'fs'
|
|
4
|
+
import { join } from 'path'
|
|
3
5
|
|
|
4
6
|
export default defineContract({
|
|
5
7
|
venue: 'itch',
|
|
@@ -10,7 +12,20 @@ export default defineContract({
|
|
|
10
12
|
artifacts: [
|
|
11
13
|
// one AI-disclosure wording everywhere — page.md is its canonical home
|
|
12
14
|
md('.rv-itch/page.md', { mustContain: ['## AI disclosure'] }),
|
|
13
|
-
|
|
15
|
+
// every field of itch's create-project form, answered and ready to
|
|
16
|
+
// paste — the listing is a browser-only act, so the artifact IS the
|
|
17
|
+
// handoff (references/listing.template.md is the fill-in copy)
|
|
18
|
+
md('.rv-itch/listing.md', { mustContain: ['## Title', '## Short description or tagline', '## Classification', '## Genre', '## Tags', '## Visibility'] }),
|
|
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
|
+
custom('.rv-itch/listing.md answered', (env) => {
|
|
22
|
+
const p = join(env.root, '.rv-itch/listing.md')
|
|
23
|
+
if (!existsSync(p)) return []
|
|
24
|
+
const left = [...readFileSync(p, 'utf8').matchAll(/<[a-z][^<>\n]{2,60}>/g)].map((m) => m[0])
|
|
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`]
|
|
26
|
+
}),
|
|
27
|
+
// itch DISPLAYS 630x500 and accepts larger — atLeast, never exact-size
|
|
28
|
+
png('.rv-itch/cover.png', 630, 500, { atLeast: true }),
|
|
14
29
|
glob('.rv-itch/screenshots/*.png', { min: 3 }),
|
|
15
30
|
],
|
|
16
31
|
})
|
|
@@ -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
|
}
|