rvlib-shipkit 0.11.0 → 0.14.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 +200 -0
- package/README.md +17 -3
- package/package.json +6 -3
- package/src/asks/asks-queue.ts +8 -0
- package/src/assets/kit-config.ts +2 -3
- package/src/assets/roll-workspace.ts +2 -1
- package/src/assets/run-assets.ts +0 -11
- package/src/assets/server/studio-server.ts +7 -24
- package/src/cli/cmd_Assets.ts +3 -14
- package/src/cli/cmd_Check.ts +22 -4
- package/src/cli/cmd_Reflect.ts +116 -0
- package/src/cli/cmd_Rules.ts +32 -0
- package/src/cli/cmd_Serve.ts +1 -1
- package/src/cli.ts +5 -2
- package/src/collections/actions.ts +19 -0
- package/src/collections/collection-types.ts +13 -0
- package/src/collections/graphs.ts +34 -0
- package/src/collections/pings.ts +5 -1
- package/src/collections/posts.ts +20 -1
- package/src/collections/providers.ts +163 -50
- package/src/contracts/apply-contracts.ts +47 -0
- package/src/contracts/contract-types.ts +5 -2
- package/src/contracts/contracts-engine.ts +13 -3
- package/src/contracts/manifests/discord.contract.ts +1 -1
- package/src/contracts/manifests/identity.contract.ts +1 -1
- package/src/contracts/manifests/itch.contract.ts +19 -2
- package/src/contracts/run-contracts.ts +8 -3
- package/src/core/canonical-scripts.ts +46 -0
- package/src/core/check-suite.ts +6 -3
- package/src/core/locked-write.ts +39 -0
- package/src/core/motherhome.ts +87 -0
- package/src/core/run-check.ts +28 -15
- package/src/core/run-ci.ts +4 -0
- package/src/core/write-status.ts +7 -26
- package/src/init-repo.ts +27 -11
- package/src/reflect/prompt-corpus.ts +305 -0
- package/src/repo-config.ts +30 -38
- package/src/rules/SK001-package-json-shape.ts +156 -0
- package/src/rules/SK002-contracts-fulfillness.ts +19 -0
- package/src/rules/SK003-claude-md-shape.ts +64 -0
- package/src/rules/SK004-gitignore-policy.ts +105 -0
- package/src/rules/SK005-rvlib-versions.ts +99 -0
- package/src/rules/SK006-npm-publish-safety.ts +42 -0
- package/src/rules/SK007-github-visibility.ts +63 -0
- package/src/rules/SK008-reviewer-agent.ts +98 -0
- package/src/rules/rule-registry.ts +34 -0
- package/src/rules/rule.ts +62 -0
- package/src/rules/run-fix.ts +57 -0
- package/src/serve/hub-app/col-card.tsx +192 -0
- package/src/serve/hub-app/cols-svc.ts +136 -0
- package/src/serve/hub-app/geometry-view.tsx +155 -0
- package/src/serve/hub-app/health-svc.ts +49 -0
- package/src/serve/hub-app/hub-app.tsx +236 -215
- package/src/serve/hub-app/hub-st.ts +169 -0
- package/src/serve/hub-app/hub-types.ts +72 -0
- package/src/serve/hub-app/hub.css +286 -59
- package/src/serve/hub-app/index.html +1 -1
- package/src/serve/hub-app/md.ts +24 -0
- package/src/serve/hub-app/media-svc.ts +429 -0
- package/src/serve/hub-app/media-view.tsx +299 -0
- package/src/serve/run-serve.ts +23 -9
- package/src/serve/serve-hub.ts +62 -47
- package/src/shipkit.ts +9 -5
- package/src/assets/server/studio-page.ts +0 -687
- package/src/core/package-json-check.ts +0 -88
- package/src/serve/hub-pages.ts +0 -38
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// shipkit rules — render the SK rule registry. Agents read ids HERE (or
|
|
2
|
+
// --json), never guess or mint one: the registry is the single source.
|
|
3
|
+
import { Command, Option } from 'clipanion'
|
|
4
|
+
import { RULES } from '../rules/rule-registry.ts'
|
|
5
|
+
|
|
6
|
+
export class RulesCommand extends Command {
|
|
7
|
+
static override paths = [['rules']]
|
|
8
|
+
|
|
9
|
+
static override usage = Command.Usage({
|
|
10
|
+
category: 'Health',
|
|
11
|
+
description: 'list every built-in SK rule: id, what it lints, owning doc, whether check --fix can repair it',
|
|
12
|
+
examples: [
|
|
13
|
+
['list rules', 'shipkit rules'],
|
|
14
|
+
['machine-readable', 'shipkit rules --json'],
|
|
15
|
+
],
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
json = Option.Boolean('--json', false, { description: 'machine-readable [{id, title, owner, fixable}]' })
|
|
19
|
+
|
|
20
|
+
async execute(): Promise<number> {
|
|
21
|
+
const rows = RULES.map((r) => ({ id: r.id, title: r.title, owner: r.owner, fixable: r.fix != null }))
|
|
22
|
+
if (this.json) {
|
|
23
|
+
this.context.stdout.write(`${JSON.stringify(rows)}\n`)
|
|
24
|
+
return 0
|
|
25
|
+
}
|
|
26
|
+
// a catalog, not a health run — no wire prefixes (`ok:` would assert
|
|
27
|
+
// a passing check into anything that parses wire output)
|
|
28
|
+
for (const r of rows)
|
|
29
|
+
this.context.stdout.write(`${r.id} ${r.title} — ${r.owner}${r.fixable ? ' · fixable' : ''}\n`)
|
|
30
|
+
return 0
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/cli/cmd_Serve.ts
CHANGED
|
@@ -8,7 +8,7 @@ export class ServeCommand extends Command {
|
|
|
8
8
|
|
|
9
9
|
static override usage = Command.Usage({
|
|
10
10
|
category: 'Serve',
|
|
11
|
-
description: 'ONE
|
|
11
|
+
description: 'ONE server, ONE page: media studio + asks + creds + posts + health + graphs, with drill-down graph pages',
|
|
12
12
|
examples: [
|
|
13
13
|
['serve the repo toolbox', 'shipkit serve'],
|
|
14
14
|
['pin the port for this launch', 'shipkit serve --port 4804'],
|
package/src/cli.ts
CHANGED
|
@@ -5,13 +5,15 @@
|
|
|
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
|
-
import { AssetsAddCommand, AssetsCheckCommand, AssetsFillCommand, AssetsGenCommand, AssetsIdentityCommand
|
|
8
|
+
import { AssetsAddCommand, AssetsCheckCommand, AssetsFillCommand, AssetsGenCommand, AssetsIdentityCommand } from './cli/cmd_Assets.ts'
|
|
9
9
|
import { CheckCommand } from './cli/cmd_Check.ts'
|
|
10
10
|
import { CiCommand } from './cli/cmd_Ci.ts'
|
|
11
11
|
import { ContractsCommand } from './cli/cmd_Contracts.ts'
|
|
12
12
|
import { InitCommand } from './cli/cmd_Init.ts'
|
|
13
13
|
import { MvCommand } from './cli/cmd_Mv.ts'
|
|
14
14
|
import { NeedsCommand } from './cli/cmd_Needs.ts'
|
|
15
|
+
import { ReflectCommand } from './cli/cmd_Reflect.ts'
|
|
16
|
+
import { RulesCommand } from './cli/cmd_Rules.ts'
|
|
15
17
|
import { ServeCommand } from './cli/cmd_Serve.ts'
|
|
16
18
|
import { StartCommand } from './cli/cmd_Start.ts'
|
|
17
19
|
import { TuiCommand } from './cli/cmd_Tui.ts'
|
|
@@ -40,7 +42,6 @@ cli.register(AsksCheckCommand)
|
|
|
40
42
|
cli.register(AskNewCommand)
|
|
41
43
|
cli.register(WikiServeCommand)
|
|
42
44
|
cli.register(WikiLintCommand)
|
|
43
|
-
cli.register(AssetsServeCommand)
|
|
44
45
|
cli.register(AssetsFillCommand)
|
|
45
46
|
cli.register(AssetsAddCommand)
|
|
46
47
|
cli.register(AssetsGenCommand)
|
|
@@ -49,5 +50,7 @@ cli.register(AssetsCheckCommand)
|
|
|
49
50
|
cli.register(InitCommand)
|
|
50
51
|
cli.register(StartCommand)
|
|
51
52
|
cli.register(NeedsCommand)
|
|
53
|
+
cli.register(ReflectCommand)
|
|
54
|
+
cli.register(RulesCommand)
|
|
52
55
|
|
|
53
56
|
cli.runExit(process.argv.slice(2))
|
|
@@ -55,6 +55,25 @@ export function appendPostFeedback(root: string, channel: string, file: string,
|
|
|
55
55
|
writeFileSync(p, /^## Feedback\s*$/m.test(md) ? `${md.trimEnd()}\n${line}\n` : `${md.trimEnd()}\n\n## Feedback\n${line}\n`)
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/** his asset remark → `.rv-assets/notes.md` (git-tracked; the studio
|
|
59
|
+
* workspace is gitignored, so notes live beside kit.ts) */
|
|
60
|
+
export function appendAssetNote(root: string, assetId: string, text: string): void {
|
|
61
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(assetId)) throw new Error('bad asset id')
|
|
62
|
+
const p = join(root, '.rv-assets', 'notes.md')
|
|
63
|
+
const line = `- ${today()} [${assetId}]: ${text.replace(/\n+/g, ' ').trim()}`
|
|
64
|
+
writeFileSync(p, existsSync(p) ? `${readFileSync(p, 'utf8').trimEnd()}\n${line}\n` : `# asset notes — his remarks, read by agents at boot\n\n${line}\n`)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** his remark on an ask → appended to the ask file, dated, non-destructive.
|
|
68
|
+
* Accepts exactly one optional `done/` prefix (archived asks stay
|
|
69
|
+
* annotatable); any other slash or traversal is a bad path. */
|
|
70
|
+
export function appendAskRemark(root: string, asksDir: string, file: string, text: string): void {
|
|
71
|
+
if (!/^(done\/)?[A-Za-z0-9][A-Za-z0-9._-]*$/.test(file) || file.includes('..')) throw new Error('bad path')
|
|
72
|
+
const p = join(root, asksDir, file)
|
|
73
|
+
if (!existsSync(p)) throw new Error(`no such ask: ${file}`)
|
|
74
|
+
writeFileSync(p, `${readFileSync(p, 'utf8').trimEnd()}\n- ${today()}: ${text.replace(/\n+/g, ' ').trim()}\n`)
|
|
75
|
+
}
|
|
76
|
+
|
|
58
77
|
export function askDone(root: string, asksDir: string, file: string): void {
|
|
59
78
|
if (file.includes('/') || file.includes('..')) throw new Error('bad path')
|
|
60
79
|
archiveAsk(join(root, asksDir), file)
|
|
@@ -23,6 +23,19 @@ export type Instance = {
|
|
|
23
23
|
verbs?: CurationVerb[]
|
|
24
24
|
/** roll-strip analog: pickable variants (post drafts, rolls) */
|
|
25
25
|
candidates?: { id: string; label: string; hint?: string }[]
|
|
26
|
+
/** TRUST: the git file this instance IS — shown on the tile; the file
|
|
27
|
+
* always wins over the page */
|
|
28
|
+
file?: string
|
|
29
|
+
/** thumbnail URL for media tiles */
|
|
30
|
+
img?: string
|
|
31
|
+
/** his latest remarks from the item's own file — agents boot on these */
|
|
32
|
+
remarks?: string[]
|
|
33
|
+
/** the truth file's markdown BODY (frontmatter stripped) — clamped card
|
|
34
|
+
* in the grid, full in the item view; capped server-side, never a dump */
|
|
35
|
+
md?: string
|
|
36
|
+
/** an open ask owns the action (dedup = acted-on once, never hidden):
|
|
37
|
+
* the instance stays red HERE but verbs and pings live with the ask */
|
|
38
|
+
askOwned?: boolean
|
|
26
39
|
}
|
|
27
40
|
|
|
28
41
|
export type Collection = {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// graphs collection — the mounted md graphs as CHECKED section entries:
|
|
2
|
+
// each graph instance carries its lint state (orphans + dangling refs =
|
|
3
|
+
// yellow with counts; clean = quiet). Custom wiki-graph providers (repos
|
|
4
|
+
// with their own loader + their own lint) get a link-only instance — their
|
|
5
|
+
// repo lint owns correctness, never double-judged here.
|
|
6
|
+
import { existsSync } from 'fs'
|
|
7
|
+
import { join } from 'path'
|
|
8
|
+
import { lintMd } from '../wiki/lint-md.ts'
|
|
9
|
+
import type { Collection, Instance } from './collection-types.ts'
|
|
10
|
+
|
|
11
|
+
export function graphsCollection(root: string, o: { customWiki: boolean; agentIndex?: string }): Collection {
|
|
12
|
+
return {
|
|
13
|
+
id: 'graphs',
|
|
14
|
+
title: 'graphs',
|
|
15
|
+
media: 'data',
|
|
16
|
+
list: async () => {
|
|
17
|
+
const out: Instance[] = []
|
|
18
|
+
const checked = (id: string, title: string, href: string, dir: string, index?: string): Instance => {
|
|
19
|
+
const r = lintMd({ root: dir, index })
|
|
20
|
+
const issues = r.orphans.length + r.dangling.length
|
|
21
|
+
return {
|
|
22
|
+
id,
|
|
23
|
+
text: issues === 0 ? title : `${title} — ${r.orphans.length} orphan(s), ${r.dangling.length} dangling ref(s)`,
|
|
24
|
+
severity: issues === 0 ? undefined : 'yellow',
|
|
25
|
+
href,
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (o.customWiki) out.push({ id: 'graph:wiki', text: 'wiki graph (repo-owned lint)', href: '/graph/wiki' })
|
|
29
|
+
else out.push(checked('graph:wiki', 'wiki graph', '/graph/wiki', root))
|
|
30
|
+
if (existsSync(join(root, 'agent'))) out.push(checked('graph:agent', 'agent graph', '/graph/agent', join(root, 'agent'), o.agentIndex))
|
|
31
|
+
return out
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/collections/pings.ts
CHANGED
|
@@ -17,6 +17,9 @@ export type PingIo = {
|
|
|
17
17
|
notifyCommand?: string
|
|
18
18
|
/** additive desktop notification on macOS */
|
|
19
19
|
osascript?: boolean
|
|
20
|
+
/** hub URL appended to the message — the OFF-MACHINE form
|
|
21
|
+
* (serve.publicHost); a phone can't resolve localhost */
|
|
22
|
+
link?: string
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
const stateDir = (root: string): string | null => (existsSync(join(root, 'node_modules')) ? join(root, 'node_modules', '.cache', 'shipkit') : null)
|
|
@@ -46,6 +49,7 @@ export function pingTick(root: string, asksDir: string, needs: NeedItem[], io: P
|
|
|
46
49
|
const fresh: NeedItem[] = []
|
|
47
50
|
for (const n of needs) {
|
|
48
51
|
if (n.severity !== 'red') continue
|
|
52
|
+
if (n.askOwned === true) continue // the covering ask's pinged: field already fired the once-ever ping
|
|
49
53
|
if (n.collection === 'asks') {
|
|
50
54
|
// ask file owns the once-ever marker (shared with agent pings)
|
|
51
55
|
const file = n.id.replace(/^ask:/, '')
|
|
@@ -58,7 +62,7 @@ export function pingTick(root: string, asksDir: string, needs: NeedItem[], io: P
|
|
|
58
62
|
savePinged(root, pinged)
|
|
59
63
|
if (fresh.length === 0) return []
|
|
60
64
|
const head = fresh.slice(0, 3).map((n) => n.text.slice(0, 80)).join(' · ')
|
|
61
|
-
const message = `${fresh.length} new item(s) need you: ${head}${fresh.length > 3 ? ' · …' : ''}`
|
|
65
|
+
const message = `${fresh.length} new item(s) need you: ${head}${fresh.length > 3 ? ' · …' : ''}${io.link != null ? ` → ${io.link}` : ''}`
|
|
62
66
|
// item texts come from repo data — strip shell-active chars before the template substitution
|
|
63
67
|
const safe = message.replace(/[`$\\'"]/g, '')
|
|
64
68
|
if (io.notifyCommand != null) io.run(['sh', '-c', io.notifyCommand.replaceAll('{message}', safe)])
|
package/src/collections/posts.ts
CHANGED
|
@@ -66,13 +66,19 @@ export function socialPostsCollection(root: string): Collection {
|
|
|
66
66
|
bySlug.set(key, [...(bySlug.get(key) ?? []), p])
|
|
67
67
|
}
|
|
68
68
|
const out: Instance[] = []
|
|
69
|
+
const feedback = (v: PostFile): string[] | undefined => {
|
|
70
|
+
const lines = v.body.split('\n').filter((l) => /^- \d{4}-\d{2}-\d{2}: /.test(l))
|
|
71
|
+
return lines.length > 0 ? lines.slice(-3) : undefined
|
|
72
|
+
}
|
|
69
73
|
for (const [key, variants] of bySlug) {
|
|
74
|
+
const fileOf = (v: PostFile): string => `.rv-social/${v.channel}/${v.file}`
|
|
70
75
|
const missing = variants.filter((v) => v.status == null)
|
|
71
76
|
if (missing.length > 0) {
|
|
72
77
|
out.push({
|
|
73
78
|
id: `post:${key}:invalid`,
|
|
74
79
|
text: `${key} — missing/invalid status: frontmatter (${missing.map((v) => v.file).join(', ')})`,
|
|
75
80
|
severity: 'red',
|
|
81
|
+
file: fileOf(missing[0]!),
|
|
76
82
|
})
|
|
77
83
|
continue
|
|
78
84
|
}
|
|
@@ -85,6 +91,8 @@ export function socialPostsCollection(root: string): Collection {
|
|
|
85
91
|
artifact: { copy: postSection(picked.body) },
|
|
86
92
|
verbs: ['paste-proof', 'rate'],
|
|
87
93
|
candidates: [{ id: picked.file, label: picked.variant ?? 'post' }],
|
|
94
|
+
file: fileOf(picked),
|
|
95
|
+
remarks: feedback(picked),
|
|
88
96
|
})
|
|
89
97
|
continue
|
|
90
98
|
}
|
|
@@ -96,9 +104,20 @@ export function socialPostsCollection(root: string): Collection {
|
|
|
96
104
|
severity: 'yellow',
|
|
97
105
|
verbs: ['pick', 'rate'],
|
|
98
106
|
candidates: drafts.map((v) => ({ id: v.file, label: v.variant ?? 'draft', hint: postSection(v.body).slice(0, 200) })),
|
|
107
|
+
file: fileOf(drafts[0]!),
|
|
108
|
+
remarks: feedback(drafts[0]!),
|
|
99
109
|
})
|
|
110
|
+
continue
|
|
100
111
|
}
|
|
101
|
-
// posted / rejected
|
|
112
|
+
// posted / rejected history: VISIBLE (browse-all), never in needs
|
|
113
|
+
const posted = variants.find((v) => v.status === 'posted')
|
|
114
|
+
const shown = posted ?? variants[0]!
|
|
115
|
+
out.push({
|
|
116
|
+
id: `post:${key}:${posted != null ? 'posted' : 'rejected'}`,
|
|
117
|
+
text: `${key} — ${posted != null ? `posted${posted.posted != null ? ` (${posted.posted})` : ''}` : 'rejected'}`,
|
|
118
|
+
file: fileOf(shown),
|
|
119
|
+
remarks: feedback(shown),
|
|
120
|
+
})
|
|
102
121
|
}
|
|
103
122
|
return out
|
|
104
123
|
},
|
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
// The built-in collection providers — each one REPLACED an attentionCollector
|
|
2
2
|
// guard (the collector is gone; never reintroduce a parallel projection).
|
|
3
3
|
// Behaviors preserved by name: per-source failure isolation (projectNeeds
|
|
4
|
-
// guards per collection),
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// ask
|
|
4
|
+
// guards per collection), the identity self-heal before surfacing, and the
|
|
5
|
+
// asks READ path never minting asks/ on a repo without one. Dedup doctrine
|
|
6
|
+
// (his correction 2026-07-23): dedup = acted-on once, NEVER hidden — an
|
|
7
|
+
// ask-covered missing cred still shows RED in secrets with a pointer to the
|
|
8
|
+
// ask; verbs and pings stay with the ask. Each wire line of STATUS.md lives
|
|
9
|
+
// in exactly ONE collection: cred probe lines in secrets, everything else
|
|
10
|
+
// in repo health.
|
|
8
11
|
import { existsSync, readFileSync } from 'fs'
|
|
9
12
|
import { join } from 'path'
|
|
10
13
|
import { isStale } from '../assets/gen/derived.ts'
|
|
11
|
-
import { allAssets, type ResolvedKit } from '../assets/kit-config.ts'
|
|
14
|
+
import { allAssets, mediaOf, type ResolvedKit } from '../assets/kit-config.ts'
|
|
12
15
|
import { assetDir, listRolls, readPick } from '../assets/roll-workspace.ts'
|
|
13
16
|
import { healIdentity } from '../assets/run-assets.ts'
|
|
14
|
-
import { openAsks } from '../asks/asks-queue.ts'
|
|
17
|
+
import { doneAsks, openAsks } from '../asks/asks-queue.ts'
|
|
15
18
|
import { evaluateContracts } from '../contracts/contracts-engine.ts'
|
|
16
|
-
import {
|
|
19
|
+
import { parseWireOutput } from '../core/wire.ts'
|
|
20
|
+
import type { RepoConfig } from '../repo-config.ts'
|
|
21
|
+
import { activeContracts, contractsConfigFrom } from '../contracts/apply-contracts.ts'
|
|
17
22
|
import type { Collection, Instance } from './collection-types.ts'
|
|
18
23
|
import { socialPostsCollection } from './posts.ts'
|
|
19
24
|
|
|
@@ -26,50 +31,126 @@ const openAsksSafe = (root: string, c: RepoConfig): ReturnType<typeof openAsks>
|
|
|
26
31
|
|
|
27
32
|
const CRED_LINE = /cred ([a-z0-9-]+\/[a-z0-9-]+\/[a-z0-9-]+) missing/
|
|
28
33
|
|
|
34
|
+
/** STATUS.md's check output, regrouped: one item per ok/WARN/FAIL wire line
|
|
35
|
+
* with its indented fix: pointers riding along. The writer's markdown
|
|
36
|
+
* wrapper (comment, title, verdict, fences) parses as notes and drops out. */
|
|
37
|
+
type WireItem = { kind: 'ok' | 'warn' | 'fail'; text: string; fixes: string[] }
|
|
38
|
+
const statusWire = (root: string): WireItem[] => {
|
|
39
|
+
const p = join(root, 'STATUS.md')
|
|
40
|
+
if (!existsSync(p)) return []
|
|
41
|
+
const items: WireItem[] = []
|
|
42
|
+
for (const l of parseWireOutput(readFileSync(p, 'utf8'))) {
|
|
43
|
+
if (l.kind === 'ok' || l.kind === 'warn' || l.kind === 'fail') items.push({ kind: l.kind, text: l.text, fixes: [] })
|
|
44
|
+
else if ((l.kind === 'fix-warn' || l.kind === 'fix-fail') && items.length > 0) items[items.length - 1]!.fixes.push(l.text)
|
|
45
|
+
}
|
|
46
|
+
return items
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** wire lines re-rendered as a fenced block for an instance's md */
|
|
50
|
+
const wireMd = (lines: string[]): string => `\`\`\`\n${lines.join('\n')}\n\`\`\``
|
|
51
|
+
|
|
29
52
|
export function asksCollection(root: string, c: RepoConfig): Collection {
|
|
30
53
|
return {
|
|
31
54
|
id: 'asks',
|
|
32
|
-
title: 'asks
|
|
55
|
+
title: 'asks',
|
|
33
56
|
media: 'text',
|
|
34
|
-
list: async () =>
|
|
35
|
-
|
|
57
|
+
list: async () => {
|
|
58
|
+
const rel = c.asks?.dir ?? 'asks'
|
|
59
|
+
const open = openAsksSafe(root, c).map((a): Instance => ({
|
|
36
60
|
id: `ask:${a.file}`,
|
|
37
61
|
text: `${a.title}${a.kind !== '' ? ` (${a.kind})` : ''}`,
|
|
38
62
|
severity: 'red' as const,
|
|
39
63
|
verbs: ['done' as const, 'paste-proof' as const],
|
|
40
|
-
|
|
64
|
+
file: `${rel}/${a.file}`,
|
|
65
|
+
md: askMd(join(root, rel, a.file)),
|
|
66
|
+
remarks: lastRemarks(join(root, rel, a.file)),
|
|
67
|
+
}))
|
|
68
|
+
// the done/ archive rides along QUIET (no severity) — past answers
|
|
69
|
+
// stay consultable and remarkable; newest first, after every open ask
|
|
70
|
+
const done = doneAsks(askDirOf(root, c)).map((a): Instance => ({
|
|
71
|
+
id: `ask:done/${a.file}`,
|
|
72
|
+
text: `${a.title}${a.kind !== '' ? ` (${a.kind})` : ''}`,
|
|
73
|
+
file: `${rel}/done/${a.file}`,
|
|
74
|
+
md: askMd(join(root, rel, 'done', a.file)),
|
|
75
|
+
remarks: lastRemarks(join(root, rel, 'done', a.file)),
|
|
76
|
+
}))
|
|
77
|
+
return [...open, ...done]
|
|
78
|
+
},
|
|
41
79
|
}
|
|
42
80
|
}
|
|
43
81
|
|
|
82
|
+
// a card, not a document dump — the cut is loud (…) so a clamped card never
|
|
83
|
+
// passes for the whole truth file
|
|
84
|
+
const MD_CAP = 4000
|
|
85
|
+
|
|
86
|
+
/** the ask file's markdown body: frontmatter stripped, trimmed, capped */
|
|
87
|
+
const askMd = (path: string): string | undefined => {
|
|
88
|
+
if (!existsSync(path)) return undefined
|
|
89
|
+
const body = readFileSync(path, 'utf8').replace(/^---\n[\s\S]*?\n---\n?/, '').trim()
|
|
90
|
+
if (body === '') return undefined
|
|
91
|
+
// code-point slice — a UTF-16 cut can strand half a surrogate pair
|
|
92
|
+
return body.length > MD_CAP ? `${[...body].slice(0, MD_CAP).join('')}…` : body
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** his latest dated remark lines from a file's tail — the boot briefing */
|
|
96
|
+
const lastRemarks = (path: string, max = 3): string[] | undefined => {
|
|
97
|
+
if (!existsSync(path)) return undefined
|
|
98
|
+
const lines = readFileSync(path, 'utf8').split('\n').filter((l) => /^- \d{4}-\d{2}-\d{2}: /.test(l))
|
|
99
|
+
return lines.length > 0 ? lines.slice(-max) : undefined
|
|
100
|
+
}
|
|
101
|
+
|
|
44
102
|
/** repo-declared secrets (repo.config `creds:`) + cred FAIL lines from
|
|
45
|
-
* STATUS.md — the tile carries the mint artifact as data.
|
|
46
|
-
*
|
|
103
|
+
* STATUS.md — the tile carries the mint artifact as data. EVERY declared
|
|
104
|
+
* cred is listed. Missing + unclaimed = red with the set verb + artifact;
|
|
105
|
+
* missing + ask-covered = STILL red (never hidden), no verbs — the text
|
|
106
|
+
* points at the ask that owns the action. The probe FAIL line (+ its fix:
|
|
107
|
+
* pointers) rides the instance's md, never a health line.
|
|
108
|
+
* Declared-and-present creds show QUIET, the vault is consultable. */
|
|
47
109
|
export function credsCollection(root: string, c: RepoConfig): Collection {
|
|
48
110
|
return {
|
|
49
111
|
id: 'creds',
|
|
50
|
-
title: '
|
|
112
|
+
title: 'secrets',
|
|
51
113
|
media: 'pointer',
|
|
52
114
|
list: async () => {
|
|
53
|
-
const
|
|
54
|
-
const
|
|
115
|
+
const rel = c.asks?.dir ?? 'asks'
|
|
116
|
+
const asks = openAsksSafe(root, c)
|
|
117
|
+
const coveringAsk = (name: string): { file: string } | undefined =>
|
|
118
|
+
asks.find((a) => a.title.toLowerCase().includes(name.split('/')[1] ?? name))
|
|
119
|
+
// only a FAIL probe proves missing — a WARN/narrative mention never reds
|
|
120
|
+
const probes = new Map<string, WireItem>()
|
|
121
|
+
for (const i of statusWire(root)) {
|
|
122
|
+
if (i.kind !== 'fail') continue
|
|
123
|
+
const name = i.text.match(CRED_LINE)?.[1]
|
|
124
|
+
if (name != null && !probes.has(name)) probes.set(name, i)
|
|
125
|
+
}
|
|
55
126
|
const out: Instance[] = []
|
|
56
|
-
const
|
|
57
|
-
const statusPath = join(root, 'STATUS.md')
|
|
58
|
-
const statusCreds = existsSync(statusPath)
|
|
59
|
-
? readFileSync(statusPath, 'utf8')
|
|
60
|
-
.split('\n')
|
|
61
|
-
.filter((l) => l.trimStart().startsWith('FAIL')) // only a FAIL probe proves missing — a WARN/narrative mention never reds
|
|
62
|
-
.map((l) => l.match(CRED_LINE)?.[1])
|
|
63
|
-
.filter((n): n is string => n != null)
|
|
64
|
-
: []
|
|
65
|
-
for (const name of statusCreds) {
|
|
66
|
-
if (seen.has(name) || covered(name)) continue
|
|
67
|
-
seen.add(name)
|
|
127
|
+
for (const [name, probe] of probes) {
|
|
68
128
|
const decl = (c.creds ?? []).find((d) => d.name === name)
|
|
69
|
-
|
|
129
|
+
const md = wireMd([`FAIL: ${probe.text}`, ...probe.fixes.map((f) => `fix: ${f}`)])
|
|
130
|
+
const ask = coveringAsk(name)
|
|
131
|
+
if (ask != null)
|
|
132
|
+
// dedup = acted-on once, never hidden: the ask keeps the
|
|
133
|
+
// verbs and the ping; the cred still shows RED here
|
|
134
|
+
out.push({
|
|
135
|
+
id: `cred:${name}`,
|
|
136
|
+
text: `${name} missing${decl?.service != null ? ` — ${decl.service}` : ''} — ask ${rel}/${ask.file} owns this`,
|
|
137
|
+
severity: 'red',
|
|
138
|
+
md,
|
|
139
|
+
askOwned: true,
|
|
140
|
+
})
|
|
141
|
+
else out.push({ ...credInstance(name, c.credsCommand, decl), md })
|
|
142
|
+
}
|
|
143
|
+
// every declared cred not proven missing is a quiet instance —
|
|
144
|
+
// present secrets carry no severity and no copy command (nothing
|
|
145
|
+
// to run), just the mint pointer when one is declared
|
|
146
|
+
for (const d of c.creds ?? []) {
|
|
147
|
+
if (probes.has(d.name)) continue
|
|
148
|
+
out.push({
|
|
149
|
+
id: `cred:${d.name}`,
|
|
150
|
+
text: `${d.name}${d.service != null ? ` — ${d.service}` : ''}`,
|
|
151
|
+
artifact: d.mintUrl != null ? { url: d.mintUrl } : undefined,
|
|
152
|
+
})
|
|
70
153
|
}
|
|
71
|
-
// declared-but-not-probed creds stay quiet: only a STATUS.md
|
|
72
|
-
// probe line proves "missing" — declaration alone never reds
|
|
73
154
|
return out
|
|
74
155
|
},
|
|
75
156
|
}
|
|
@@ -85,9 +166,11 @@ const credInstance = (name: string, cmdTemplate: string | undefined, decl?: { se
|
|
|
85
166
|
verbs: ['set'],
|
|
86
167
|
})
|
|
87
168
|
|
|
88
|
-
/** media kit instances —
|
|
89
|
-
*
|
|
90
|
-
*
|
|
169
|
+
/** media kit instances — BROWSE-ALL: every kit asset is an instance (the
|
|
170
|
+
* page shows everything that exists; healthy = no severity, dim tile).
|
|
171
|
+
* Identity self-heals BEFORE surfacing; manual with nothing uploaded =
|
|
172
|
+
* red (provide); rolls with no pick = yellow (a default exists, taste
|
|
173
|
+
* pending — auto-pick-first is blessed); stale = yellow. */
|
|
91
174
|
export function mediaKitCollection(root: string, kit: ResolvedKit | null, heal: typeof healIdentity = healIdentity): Collection {
|
|
92
175
|
return {
|
|
93
176
|
id: 'media',
|
|
@@ -97,29 +180,57 @@ export function mediaKitCollection(root: string, kit: ResolvedKit | null, heal:
|
|
|
97
180
|
const idn = await heal(root)
|
|
98
181
|
const out: Instance[] = []
|
|
99
182
|
if (idn.state === 'blocked')
|
|
100
|
-
out.push({ id: 'media:identity', text: `identity — ${idn.detail ?? 'blocked'}`, severity: 'red', href: '
|
|
183
|
+
out.push({ id: 'media:identity', text: `identity — ${idn.detail ?? 'blocked'}`, severity: 'red', href: '/#identity-logo', verbs: ['pick'] })
|
|
101
184
|
if (kit == null) return out
|
|
185
|
+
const notes = assetNotes(root)
|
|
102
186
|
for (const f of allAssets(kit)) {
|
|
103
187
|
const dir = assetDir(join(root, kit.workspace), f.asset.id)
|
|
104
188
|
const rolls = listRolls(dir)
|
|
105
189
|
const manual = f.kind.generate.backend === 'manual'
|
|
106
|
-
const
|
|
190
|
+
const base: Instance = {
|
|
191
|
+
id: `media:${f.asset.id}`,
|
|
192
|
+
text: f.asset.id,
|
|
193
|
+
href: `/#${f.asset.id}`,
|
|
194
|
+
// image kinds only — /api/thumb serves raw roll bytes, and
|
|
195
|
+
// an mp3 inside an <img> is a broken glyph on every tile
|
|
196
|
+
img: rolls.length > 0 && mediaOf(f.kind) === 'image' ? `/media/api/thumb?id=${encodeURIComponent(f.asset.id)}` : undefined,
|
|
197
|
+
file: '.rv-assets/kit.ts',
|
|
198
|
+
remarks: notes.get(f.asset.id),
|
|
199
|
+
}
|
|
107
200
|
if (manual && rolls.length === 0)
|
|
108
201
|
// the brief IS the paste-ready artifact ("I don't even
|
|
109
202
|
// know what to paste" — the tile hands him the prompt)
|
|
110
|
-
out.push({
|
|
203
|
+
out.push({ ...base, text: `${f.asset.id} — manual, nothing uploaded`, severity: 'red', verbs: ['provide'], artifact: f.asset.prompt != null ? { copy: f.asset.prompt } : undefined })
|
|
111
204
|
else if (rolls.length > 0 && (await readPick(dir)).pick == null)
|
|
112
|
-
out.push({
|
|
205
|
+
out.push({ ...base, text: `${f.asset.id} — ${rolls.length} roll(s), default in use, none picked`, severity: 'yellow', verbs: ['pick'] })
|
|
113
206
|
else if (await isStale(kit, join(root, kit.workspace), f))
|
|
114
|
-
out.push({
|
|
207
|
+
out.push({ ...base, text: `${f.asset.id} — stale (a parent was re-picked)`, severity: 'yellow', verbs: ['pick'] })
|
|
208
|
+
else out.push(base) // healthy — visible, dim, never in needs
|
|
115
209
|
}
|
|
116
210
|
return out
|
|
117
211
|
},
|
|
118
212
|
}
|
|
119
213
|
}
|
|
120
214
|
|
|
121
|
-
/**
|
|
122
|
-
*
|
|
215
|
+
/** `.rv-assets/notes.md` — his dated asset remarks, one git-tracked file:
|
|
216
|
+
* `- YYYY-MM-DD [asset-id]: text` */
|
|
217
|
+
const assetNotes = (root: string): Map<string, string[]> => {
|
|
218
|
+
const p = join(root, '.rv-assets', 'notes.md')
|
|
219
|
+
const map = new Map<string, string[]>()
|
|
220
|
+
if (!existsSync(p)) return map
|
|
221
|
+
for (const m of readFileSync(p, 'utf8').matchAll(/^- (\d{4}-\d{2}-\d{2}) \[([^\]]+)\]: (.+)$/gm)) {
|
|
222
|
+
const key = m[2]!
|
|
223
|
+
map.set(key, [...(map.get(key) ?? []).slice(-2), `- ${m[1]}: ${m[3]}`])
|
|
224
|
+
}
|
|
225
|
+
return map
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** repo health — NON-curatable feed items: EVERY wire line of STATUS.md's
|
|
229
|
+
* check output (ok = quiet, WARN = yellow, FAIL = red; the passing lines
|
|
230
|
+
* ARE listed — the page shows the whole check run), plus contract
|
|
231
|
+
* FAIL/WARN and hub process staleness. fix: pointers ride the owning
|
|
232
|
+
* instance's md as a code block. Cred probe FAIL lines (+ their fixes) are
|
|
233
|
+
* routed to the secrets collection, never duplicated here. */
|
|
123
234
|
export function repoHealthCollection(root: string, c: RepoConfig, o?: { bootVersion?: string; libVersion?: () => string }): Collection {
|
|
124
235
|
return {
|
|
125
236
|
id: 'health',
|
|
@@ -132,15 +243,17 @@ export function repoHealthCollection(root: string, c: RepoConfig, o?: { bootVers
|
|
|
132
243
|
if (now !== o.bootVersion)
|
|
133
244
|
out.push({ id: 'health:stale-hub', severity: 'red', text: `hub is running shipkit ${o.bootVersion} but ${now} is on disk (stale process, stale pages, old studio code). Restart the 🚀 shipkit task` })
|
|
134
245
|
}
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
246
|
+
for (const i of statusWire(root)) {
|
|
247
|
+
if (i.kind === 'fail' && CRED_LINE.test(i.text)) continue // secrets own the cred probe + its fix lines
|
|
248
|
+
// the feed line carries severity already — a raw FAIL:/WARN marker would stack the signal
|
|
249
|
+
// id = full text (never sliced: a shared 60-char prefix must not collide two items — pings dedup on ids)
|
|
250
|
+
out.push({
|
|
251
|
+
id: `health:${i.text}`,
|
|
252
|
+
text: i.text,
|
|
253
|
+
severity: i.kind === 'fail' ? 'red' : i.kind === 'warn' ? 'yellow' : undefined,
|
|
254
|
+
md: i.fixes.length > 0 ? wireMd(i.fixes.map((f) => `fix: ${f}`)) : undefined,
|
|
255
|
+
})
|
|
256
|
+
}
|
|
144
257
|
const r = await evaluateContracts(activeContracts(c), contractsConfigFrom(c, root))
|
|
145
258
|
for (const l of r.lines) {
|
|
146
259
|
if (l.kind === 'fail') out.push({ id: `health:contract:${l.text}`, severity: 'red', text: l.text })
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// The ONE bridge from the contracts engine into wire output — every surface
|
|
2
|
+
// that reports contract fulfillness (`shipkit check` via rule SK002, the ci
|
|
3
|
+
// `contracts` task, `shipkit contracts`) goes through applyContractsResult,
|
|
4
|
+
// so tags, the empty-repo sentence, and severity plumbing exist exactly
|
|
5
|
+
// once. Kinds pass through raw so fix pointers and visible exempts survive
|
|
6
|
+
// every printer.
|
|
7
|
+
import { existsSync } from 'fs'
|
|
8
|
+
import { join } from 'path'
|
|
9
|
+
import type { CheckCtx } from '../core/check-ctx.ts'
|
|
10
|
+
import type { RepoConfig } from '../repo-config.ts'
|
|
11
|
+
import { tagRuleLines, type Rule } from '../rules/rule.ts'
|
|
12
|
+
import { allContracts } from './contract-manifests.ts'
|
|
13
|
+
import type { ContractManifest } from './contract-types.ts'
|
|
14
|
+
import type { ContractsConfig, ContractsResult } from './contracts-engine.ts'
|
|
15
|
+
|
|
16
|
+
export function contractsConfigFrom(c: RepoConfig, root: string): ContractsConfig {
|
|
17
|
+
const venues = c.venues ?? []
|
|
18
|
+
const social = c.social ?? []
|
|
19
|
+
const declared = [
|
|
20
|
+
...venues,
|
|
21
|
+
...(social.length > 0 ? ['social'] : []),
|
|
22
|
+
...(social.includes('discord') ? ['discord'] : []),
|
|
23
|
+
]
|
|
24
|
+
return {
|
|
25
|
+
root,
|
|
26
|
+
declared,
|
|
27
|
+
social,
|
|
28
|
+
publicSurface: venues.length > 0 || existsSync(join(root, 'deploy.sh')),
|
|
29
|
+
exempt: c.contracts?.exempt,
|
|
30
|
+
docsBase: c.docsBase,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function activeContracts(c: RepoConfig): ContractManifest[] {
|
|
35
|
+
return [...allContracts, ...(c.contracts?.extra ?? [])]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function applyContractsResult(t: CheckCtx, r: ContractsResult, tag?: Rule): void {
|
|
39
|
+
const lines = tag != null ? tagRuleLines(tag, r.lines) : r.lines
|
|
40
|
+
for (const l of lines) t.lines.push(l)
|
|
41
|
+
if (r.exitCode === 1) t.failed = true
|
|
42
|
+
if (r.lines.some((l) => l.kind === 'warn')) t.warned = true
|
|
43
|
+
if (r.lines.length === 0) {
|
|
44
|
+
const text = 'no contracts apply (nothing declared, nothing detected)'
|
|
45
|
+
t.ok(tag != null ? `[${tag.id}] ${text}` : text)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -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': {
|