rvlib-shipkit 0.38.0 → 1.0.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 +371 -16
- package/README.md +26 -14
- package/package.json +2 -2
- package/src/assets/gen/comfyts-gen.ts +27 -10
- package/src/assets/gen/generate-roll.ts +1 -1
- package/src/assets/kit-config.ts +6 -6
- package/src/assets/run-assets.ts +14 -10
- package/src/assets/server/studio-server.ts +6 -5
- package/src/check.ts +343 -0
- package/src/{rules/SK001-package-json-shape.ts → checks/SK001-package-json-shape.check.ts} +44 -46
- package/src/checks/SK002-contracts-fulfillness.check.ts +24 -0
- package/src/checks/SK003-claude-md-shape.check.ts +66 -0
- package/src/checks/SK004-gitignore-policy.check.ts +309 -0
- package/src/{rules/SK005-rvlib-versions.ts → checks/SK005-rvlib-versions.check.ts} +46 -32
- package/src/{rules/SK006-npm-publish-safety.ts → checks/SK006-npm-publish-safety.check.ts} +25 -24
- package/src/{rules/SK007-github-visibility.ts → checks/SK007-github-visibility.check.ts} +20 -14
- package/src/{rules/SK008-reviewer-agent.ts → checks/SK008-reviewer-agent.check.ts} +29 -14
- package/src/{rules/SK009-commit-ai-tells.ts → checks/SK009-commit-ai-tells.check.ts} +24 -21
- package/src/checks/SK010-undeclared-unit.check.ts +111 -0
- package/src/{rules/SK011-sibling-unit-ranges.ts → checks/SK011-sibling-unit-ranges.check.ts} +21 -20
- package/src/{rules/SK012-config-barrel-import.ts → checks/SK012-config-barrel-import.check.ts} +18 -23
- package/src/{rules/SK013-one-way-in-policy.ts → checks/SK013-one-way-in-policy.check.ts} +58 -50
- package/src/{rules/SK014-prose-ste.ts → checks/SK014-prose-ste.check.ts} +27 -13
- package/src/{rules/SK015-nested-repo.ts → checks/SK015-nested-repo.check.ts} +15 -12
- package/src/checks/SK016-legacy-check-shapes.check.ts +182 -0
- package/src/checks/SK017-vscode-hub-task.check.ts +197 -0
- package/src/checks/SK018-test-files-run.check.ts +28 -0
- package/src/checks/SK019-tests-run-in-units.check.ts +76 -0
- package/src/checks/SK020-self-spawning-check.check.ts +147 -0
- package/src/checks/contracts.check.ts +20 -0
- package/src/cli/cmd_Asks.ts +3 -2
- package/src/cli/cmd_Assets.ts +2 -2
- package/src/cli/cmd_Bump.ts +1 -1
- package/src/cli/cmd_Check.ts +6 -6
- package/src/cli/cmd_Checks.ts +95 -0
- package/src/cli/cmd_Ci.ts +48 -13
- package/src/cli/cmd_Commands.ts +2 -6
- package/src/cli/cmd_ConfigKeys.ts +69 -0
- package/src/cli/cmd_Contracts.ts +1 -1
- package/src/cli/cmd_Fleet.ts +6 -2
- package/src/cli/cmd_Hooks.ts +1 -1
- package/src/cli/cmd_Keys.ts +43 -18
- package/src/cli/cmd_Mv.ts +2 -2
- package/src/cli/cmd_Prose.ts +10 -1
- package/src/cli/cmd_Releases.ts +2 -2
- package/src/cli/cmd_Serve.ts +1 -1
- package/src/cli/cmd_Start.ts +6 -6
- package/src/cli/cmd_Status.ts +25 -0
- package/src/cli/cmd_Tui.ts +2 -2
- package/src/cli.ts +7 -4
- package/src/collections/actions.ts +4 -4
- package/src/collections/checks.ts +258 -0
- package/src/collections/collection-types.ts +254 -1
- package/src/collections/comfy.ts +23 -0
- package/src/collections/md-body.ts +14 -0
- package/src/collections/pings.ts +38 -12
- package/src/collections/posts.ts +19 -3
- package/src/collections/providers.ts +198 -91
- package/src/collections/reflections.ts +41 -0
- package/src/collections/state-rules.ts +73 -0
- package/src/collections/test-time.ts +288 -0
- package/src/collections/tests.ts +181 -0
- package/src/collections/wikis.ts +66 -0
- package/src/comfy/comfy-panel.ts +292 -0
- package/src/contracts/apply-contracts.ts +17 -12
- package/src/contracts/contract-types.ts +16 -4
- package/src/contracts/contracts-engine.ts +78 -42
- package/src/contracts/manifests/discord.contract.ts +6 -6
- package/src/contracts/manifests/identity.contract.ts +3 -3
- package/src/contracts/manifests/itch.contract.ts +12 -15
- package/src/contracts/manifests/social.contract.ts +5 -5
- package/src/contracts/manifests/steam.contract.ts +16 -16
- package/src/contracts/run-contracts.ts +4 -3
- package/src/core/attach.ts +96 -0
- package/src/core/canonical-scripts.ts +25 -0
- package/src/core/check-ctx.ts +204 -14
- package/src/core/check-file-names.ts +112 -0
- package/src/core/check-pass.ts +84 -0
- package/src/core/check-runner.ts +796 -0
- package/src/core/config-keys.ts +256 -0
- package/src/core/config-rewrite.ts +77 -0
- package/src/core/discover-checks.ts +166 -90
- package/src/core/discover-legacy-checks.ts +26 -0
- package/src/core/doc-block.ts +13 -0
- package/src/core/floor-resolve.ts +264 -0
- package/src/core/floor.ts +20 -137
- package/src/core/git-bin.ts +24 -0
- package/src/core/git-root.ts +108 -0
- package/src/core/instance.ts +195 -0
- package/src/core/jobs.ts +118 -0
- package/src/core/layout.ts +52 -0
- package/src/core/legacy-shapes.ts +207 -0
- package/src/core/lib-root.ts +5 -0
- package/src/core/locked-write.ts +110 -39
- package/src/core/machine-commit.ts +5 -4
- package/src/core/migrate-layout.ts +557 -0
- package/src/core/motherhome.ts +2 -8
- package/src/core/nesting.ts +4 -12
- package/src/core/one-way-in-policy.ts +73 -17
- package/src/core/outcome-words.ts +45 -0
- package/src/core/package-manager.ts +86 -0
- package/src/core/parallel.ts +21 -0
- package/src/core/process-tree.ts +44 -0
- package/src/core/raw-access.ts +93 -0
- package/src/core/read-set.ts +152 -0
- package/src/core/run-check.ts +155 -213
- package/src/core/run-ci.ts +153 -189
- package/src/core/run-fix.ts +96 -0
- package/src/core/run-fixers.ts +53 -30
- package/src/core/self-update.ts +9 -3
- package/src/core/slots.ts +83 -0
- package/src/core/spawn-timeout.ts +34 -3
- package/src/core/when-block.ts +401 -0
- package/src/core/when.ts +135 -0
- package/src/core/write-status.ts +5 -2
- package/src/hooks/leak-guard.ts +10 -8
- package/src/hooks/one-way-in-guard.ts +13 -5
- package/src/hooks/root-identity.ts +2 -1
- package/src/hooks/run-hooks.ts +35 -23
- package/src/init-repo.ts +39 -23
- package/src/ledger/check-stats.ts +155 -0
- package/src/ledger/check-table.ts +179 -0
- package/src/ledger/db.ts +255 -0
- package/src/ledger/gate-checks.ts +433 -0
- package/src/ledger/git-key.ts +116 -77
- package/src/ledger/git-scan.ts +96 -35
- package/src/ledger/ledger.ts +116 -42
- package/src/ledger/live-runs.ts +111 -0
- package/src/ledger/live-timeline.ts +97 -0
- package/src/ledger/repo-view.ts +73 -0
- package/src/ledger/run-log.ts +448 -0
- package/src/ledger/run-timeline.ts +173 -0
- package/src/ledger/store.ts +23 -12
- package/src/ledger/suite-log.ts +161 -0
- package/src/ledger/toolchain-identity.ts +7 -1
- package/src/ledger/world-cache.ts +111 -0
- package/src/mv/mv-md.ts +16 -6
- package/src/problems/extract.ts +182 -0
- package/src/problems/problem-board.ts +37 -0
- package/src/problems/problem-line.ts +77 -0
- package/src/prose/prose-view.ts +90 -0
- package/src/prose/run-prose.ts +60 -19
- package/src/prose/ste.ts +140 -24
- package/src/reflections/reflections-queue.ts +81 -0
- package/src/release/git-facts.ts +15 -9
- package/src/release/run-bump.ts +6 -5
- package/src/release/run-release.ts +25 -11
- package/src/release/run-releases.ts +2 -2
- package/src/release/unit-gate.ts +7 -54
- package/src/release/unit-resolve.ts +282 -0
- package/src/release/units.ts +33 -217
- package/src/repo-config.ts +155 -85
- package/src/rules/rule.ts +36 -96
- package/src/rules/untrack-generated.ts +5 -4
- package/src/scaffold/files/vscode-tasks.json +10 -31
- package/src/scaffold/jsonc-spans.ts +113 -0
- package/src/scaffold/project-scaffold.ts +16 -43
- package/src/scan.ts +98 -0
- package/src/serve/gate-trigger.ts +148 -0
- package/src/serve/health-schedule.ts +102 -0
- package/src/serve/hub-app/chart-view.ts +179 -0
- package/src/serve/hub-app/check-view.tsx +345 -0
- package/src/serve/hub-app/check-words.ts +164 -0
- package/src/serve/hub-app/checks-svc.ts +280 -0
- package/src/serve/hub-app/clock-svc.ts +33 -0
- package/src/serve/hub-app/col-card.tsx +23 -39
- package/src/serve/hub-app/cols-svc.ts +44 -20
- package/src/serve/hub-app/comfy-svc.ts +171 -0
- package/src/serve/hub-app/comfy-view.tsx +81 -0
- package/src/serve/hub-app/dur.ts +41 -0
- package/src/serve/hub-app/hash-route.ts +121 -0
- package/src/serve/hub-app/header-bar.tsx +117 -0
- package/src/serve/hub-app/health-svc.ts +23 -28
- package/src/serve/hub-app/help.tsx +29 -0
- package/src/serve/hub-app/hub-app.tsx +122 -119
- package/src/serve/hub-app/hub-st.ts +115 -42
- package/src/serve/hub-app/hub-types.ts +117 -2
- package/src/serve/hub-app/hub.css +534 -192
- package/src/serve/hub-app/index.html +2 -0
- package/src/serve/hub-app/live-svc.ts +174 -0
- package/src/serve/hub-app/md.ts +15 -0
- package/src/serve/hub-app/media-svc.ts +4 -1
- package/src/serve/hub-app/media-view.tsx +15 -0
- package/src/serve/hub-app/next-run.ts +68 -0
- package/src/serve/hub-app/project-gutter.css +65 -0
- package/src/serve/hub-app/project-gutter.tsx +128 -0
- package/src/serve/hub-app/projects-svc.ts +226 -0
- package/src/serve/hub-app/prose-hits-st.ts +124 -0
- package/src/serve/hub-app/prose-hits-view.tsx +176 -0
- package/src/serve/hub-app/prose-hits.css +21 -0
- package/src/serve/hub-app/run-strip.ts +47 -0
- package/src/serve/hub-app/runs-tabs.ts +24 -0
- package/src/serve/hub-app/test-view.tsx +107 -0
- package/src/serve/hub-app/tests-svc.ts +62 -0
- package/src/serve/hub-app/tests-view.tsx +238 -0
- package/src/serve/hub-app/time-ago.tsx +34 -0
- package/src/serve/hub-app/timeline-chart.tsx +274 -0
- package/src/serve/hub-app/timeline-geometry.ts +314 -0
- package/src/serve/hub-app/timeline-svc.ts +62 -0
- package/src/serve/hub-app/timeline-view.tsx +245 -0
- package/src/serve/hub-app/tree-build.ts +410 -0
- package/src/serve/hub-app/tree-row.tsx +42 -0
- package/src/serve/hub-app/tree-svc.ts +174 -0
- package/src/serve/hub-app/wikis-view.tsx +53 -0
- package/src/serve/other-repos.ts +79 -0
- package/src/serve/project-gutter-route.ts +350 -0
- package/src/serve/project-gutter.ts +449 -0
- package/src/serve/run-serve.ts +245 -16
- package/src/serve/same-origin.ts +14 -0
- package/src/serve/serve-hub.ts +213 -18
- package/src/shipkit.ts +19 -12
- package/src/smoke/run-smoke.ts +3 -2
- package/src/start-repo.ts +75 -36
- package/src/suite/judge-suites.ts +216 -18
- package/src/suite/kept-timings.ts +58 -0
- package/src/suite/runner-shapes.ts +88 -0
- package/src/suite/suite-cli.ts +147 -24
- package/src/suite/suite-cpu-preload.ts +92 -0
- package/src/suite/test-units.ts +243 -0
- package/src/ts/config.ts +4 -69
- package/src/ts/jsonc.ts +69 -0
- package/src/ts/lex.ts +143 -0
- package/src/ts/rename.ts +73 -10
- package/src/ts/source-graph.ts +28 -8
- package/src/ts/tsconfig-projects.ts +6 -97
- package/src/ts/tsconfig-resolve.ts +108 -0
- package/src/tui/build-steps-panel.ts +61 -0
- package/src/tui/checks-panel.ts +24 -16
- package/src/tui/run-tui.ts +2 -2
- package/src/tui/tui-panel.ts +5 -4
- package/src/wiki/graph-colors.ts +68 -0
- package/src/wiki/graph-mount.ts +7 -9
- package/src/wiki/graph-page.ts +42 -25
- package/src/wiki/graph-positions.ts +105 -0
- package/src/wiki/run-wiki.ts +1 -1
- package/src/wiki/serve-graph.ts +33 -3
- package/src/cli/cmd_Rules.ts +0 -51
- package/src/cli/cmd_Units.ts +0 -82
- package/src/collections/graphs.ts +0 -32
- package/src/contracts/contracts-once.ts +0 -43
- package/src/core/check-suite.ts +0 -302
- package/src/core/repair-contract-dirs.ts +0 -136
- package/src/core/task-runner.ts +0 -339
- package/src/ledger/gate-units.ts +0 -138
- package/src/ledger/unit-table.ts +0 -105
- package/src/rules/SK002-contracts-fulfillness.ts +0 -24
- package/src/rules/SK003-claude-md-shape.ts +0 -70
- package/src/rules/SK004-gitignore-policy.ts +0 -238
- package/src/rules/SK010-undeclared-release-unit.ts +0 -127
- package/src/rules/rule-registry.ts +0 -64
- package/src/rules/run-fix.ts +0 -72
- package/src/tui/tasks-panel.ts +0 -60
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// the comfy section's backend: the repo's .shipkit/comfy/ workflow modules, the
|
|
2
|
+
// comfy-ts serve panel the hub embeds to tune and test one, and the box those
|
|
3
|
+
// modules generate on. The contract this reads is owned by build/comfyui.md.
|
|
4
|
+
//
|
|
5
|
+
// The panel is a CHILD PROCESS rather than a route: comfy-ts owns that ui, and
|
|
6
|
+
// a second implementation of a var form inside the hub is the redundant
|
|
7
|
+
// mechanism that doctrine bans.
|
|
8
|
+
import { existsSync, readdirSync, readFileSync } from 'fs'
|
|
9
|
+
import { join, resolve } from 'path'
|
|
10
|
+
import { defaultHubPort } from '../serve/hub-port.ts'
|
|
11
|
+
|
|
12
|
+
export type ComfyWorkflow = { id: string; file: string }
|
|
13
|
+
export type ComfyPanelUp = { url: string; port: number }
|
|
14
|
+
export type ComfyBox = { url: string; err: string | null }
|
|
15
|
+
|
|
16
|
+
const SUFFIX = '.cflow.ts'
|
|
17
|
+
/** the wm facts (build/comfyui.md) — an absent key defaults to them */
|
|
18
|
+
const DEFAULT_BOX_PORT = 8085
|
|
19
|
+
|
|
20
|
+
export const comfyWorkflowsDir = (root: string): string => join(root, '.shipkit/comfy', 'workflows')
|
|
21
|
+
|
|
22
|
+
/** every workflow module, sorted. `_host.ts` carries no `.cflow` suffix on
|
|
23
|
+
* purpose, so this scan and the comfy-ts one see the same list */
|
|
24
|
+
export function scanComfyWorkflows(root: string): ComfyWorkflow[] {
|
|
25
|
+
const dir = comfyWorkflowsDir(root)
|
|
26
|
+
if (!existsSync(dir)) return []
|
|
27
|
+
return readdirSync(dir)
|
|
28
|
+
.filter((f) => f.endsWith(SUFFIX))
|
|
29
|
+
.sort()
|
|
30
|
+
.map((f) => ({ id: f.slice(0, -SUFFIX.length), file: join(dir, f) }))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** the box url .shipkit/comfy/config.json points at. An unreadable config reports
|
|
34
|
+
* its own message: probing a default the repo never declared would answer a
|
|
35
|
+
* question nobody asked */
|
|
36
|
+
export function comfyBox(root: string): ComfyBox {
|
|
37
|
+
const p = join(root, '.shipkit/comfy', 'config.json')
|
|
38
|
+
const url = (port: number): string => `http://127.0.0.1:${port}`
|
|
39
|
+
if (!existsSync(p)) return { url: url(DEFAULT_BOX_PORT), err: null }
|
|
40
|
+
try {
|
|
41
|
+
const cfg = JSON.parse(readFileSync(p, 'utf8')) as { port?: number }
|
|
42
|
+
return { url: url(typeof cfg.port === 'number' ? cfg.port : DEFAULT_BOX_PORT), err: null }
|
|
43
|
+
} catch (e) {
|
|
44
|
+
return { url: url(DEFAULT_BOX_PORT), err: `.shipkit/comfy/config.json is unreadable (${(e as Error).message}), falling back to port ${DEFAULT_BOX_PORT}` }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** is the box answering? read-only, and the ONE raw fetch this file makes */
|
|
49
|
+
export async function comfyBoxUp(url: string): Promise<boolean> {
|
|
50
|
+
return await fetch(`${url}/system_stats`, { signal: AbortSignal.timeout(2500) })
|
|
51
|
+
.then((r) => r.ok)
|
|
52
|
+
.catch(() => false)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** one house range above the hub's, from the same hash, so a repo whose hub
|
|
56
|
+
* never collides never collides here either */
|
|
57
|
+
export const defaultComfyPanelPort = (name: string): number => defaultHubPort(name) + 1000
|
|
58
|
+
|
|
59
|
+
/** what `GET /` on a comfy-ts serve answers, narrowed to the identity fields */
|
|
60
|
+
export type PanelIndex = { server?: string; workflows?: { module?: string; file?: string }[] }
|
|
61
|
+
|
|
62
|
+
/** why a server already on the panel port cannot be adopted. `stale` is a panel
|
|
63
|
+
* of OURS that booted before a module was written, which a restart fixes;
|
|
64
|
+
* `foreign` is another repo's panel or another program, which only another
|
|
65
|
+
* port fixes. Telling the two apart is the whole point: the two cures have
|
|
66
|
+
* nothing in common */
|
|
67
|
+
export type PanelVerdict = { ok: true } | { ok: false; kind: 'foreign' } | { ok: false; kind: 'stale'; missing: string[] }
|
|
68
|
+
|
|
69
|
+
/** does the server on that port serve OUR modules? The FILE paths are the
|
|
70
|
+
* identity: two repos both serving a 'txt2img-cutout' is the normal case, and
|
|
71
|
+
* adopting the wrong one would tune another repo's graph */
|
|
72
|
+
export function panelIdentity(index: PanelIndex, want: ComfyWorkflow[]): PanelVerdict {
|
|
73
|
+
if (index.server !== 'comfy-ts serve' || want.length === 0) return { ok: false, kind: 'foreign' }
|
|
74
|
+
const served = new Set((index.workflows ?? []).map((w) => resolve(w.file ?? '')))
|
|
75
|
+
const missing = want.filter((w) => !served.has(resolve(w.file)))
|
|
76
|
+
if (missing.length === 0) return { ok: true }
|
|
77
|
+
// not ONE file of ours: another repo serving the same module names
|
|
78
|
+
if (missing.length === want.length) return { ok: false, kind: 'foreign' }
|
|
79
|
+
return { ok: false, kind: 'stale', missing: missing.map((w) => w.id) }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const panelServesOurs = (index: PanelIndex, want: ComfyWorkflow[]): boolean => panelIdentity(index, want).ok
|
|
83
|
+
|
|
84
|
+
/** the refusal a reader can act on: the stale one names the modules the live
|
|
85
|
+
* panel never imported, since changing the port there fixes nothing */
|
|
86
|
+
export function panelRefusal(port: number, v: Extract<PanelVerdict, { ok: false }>): string {
|
|
87
|
+
if (v.kind === 'foreign') return `port ${port} is taken by another server — set serve: { comfyPort } in repo.config.ts to a free one`
|
|
88
|
+
return `the panel on port ${port} booted before ${v.missing.join(', ')} existed, and comfy-ts serve imports every module at boot. Use restart panel in the header to pick it up`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function probeIndex(url: string, ms = 1500): Promise<PanelIndex | null> {
|
|
92
|
+
return await fetch(url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(ms) })
|
|
93
|
+
.then((r) => (r.ok ? (r.json() as Promise<PanelIndex>) : null))
|
|
94
|
+
.catch(() => null)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** does `GET /` answer a BROWSER with a ui? comfy-ts serves its web bundle from
|
|
98
|
+
* 2.5 on, and an older one answers the json index to every request, this one
|
|
99
|
+
* included. The content type is the whole answer: the index probe above is
|
|
100
|
+
* green on both, so a panel adopted on identity alone can be a wall of json in
|
|
101
|
+
* the frame */
|
|
102
|
+
async function probeWebShell(url: string, ms = 1500): Promise<boolean> {
|
|
103
|
+
return await fetch(url, { headers: { accept: 'text/html' }, signal: AbortSignal.timeout(ms) })
|
|
104
|
+
.then((r) => r.ok && (r.headers.get('content-type') ?? '').toLowerCase().includes('text/html'))
|
|
105
|
+
.catch(() => false)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** the comfy-ts the repo would actually run, for the refusal to name */
|
|
109
|
+
export function installedComfyTsVersion(root: string): string {
|
|
110
|
+
try {
|
|
111
|
+
const raw = readFileSync(join(root, 'node_modules', 'comfy-ts', 'package.json'), 'utf8')
|
|
112
|
+
return (JSON.parse(raw) as { version?: string }).version ?? 'unknown'
|
|
113
|
+
} catch {
|
|
114
|
+
return 'unknown'
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export const noWebPanelRefusal = (version: string): string => `comfy-ts ${version} serves no web panel (needs 2.5 or later), run: bun add comfy-ts@latest`
|
|
119
|
+
|
|
120
|
+
const LOG_LINES = 40
|
|
121
|
+
// short steps, same ceilings (30s to boot, 2s to die, 3s to go quiet): a
|
|
122
|
+
// panel that is up in 80ms is not left waiting for the rest of a long step
|
|
123
|
+
const BOOT_TRIES = 600
|
|
124
|
+
const BOOT_STEP_MS = 50
|
|
125
|
+
const KILL_TRIES = 80
|
|
126
|
+
const KILL_STEP_MS = 25
|
|
127
|
+
const QUIET_TRIES = 60
|
|
128
|
+
const QUIET_STEP_MS = 50
|
|
129
|
+
|
|
130
|
+
/** the pids LISTENING on a tcp port, on darwin and linux. `-sTCP:LISTEN`
|
|
131
|
+
* carries the whole answer: `-i tcp:<port>` alone also matches every client
|
|
132
|
+
* connected to it, this process included, and those must never be killed.
|
|
133
|
+
* lsof sits in /usr/sbin on darwin, which a gui-launched shell can miss */
|
|
134
|
+
function portHolders(port: number): number[] {
|
|
135
|
+
const lsof = Bun.which('lsof') ?? ['/usr/sbin/lsof', '/usr/bin/lsof'].find((p) => existsSync(p))
|
|
136
|
+
if (lsof == null) return []
|
|
137
|
+
const r = Bun.spawnSync([lsof, '-ti', `tcp:${port}`, '-sTCP:LISTEN'], { stdout: 'pipe', stderr: 'ignore' })
|
|
138
|
+
const pids = r.stdout
|
|
139
|
+
.toString()
|
|
140
|
+
.split('\n')
|
|
141
|
+
.map((l) => Number(l.trim()))
|
|
142
|
+
.filter((n) => Number.isInteger(n) && n > 0)
|
|
143
|
+
return [...new Set(pids)]
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** a pid that died between the scan and the signal is the outcome we wanted */
|
|
147
|
+
function kill(pid: number, signal: 'SIGTERM' | 'SIGKILL'): void {
|
|
148
|
+
try {
|
|
149
|
+
process.kill(pid, signal)
|
|
150
|
+
} catch {
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** the panel process, started on the first ask and shared by every later one.
|
|
156
|
+
* Adopting a live one matters more than owning it: `bun --watch` restarts the
|
|
157
|
+
* hub on every lib edit, and the child it spawned outlives that restart */
|
|
158
|
+
export class ComfyPanel {
|
|
159
|
+
private proc: Bun.Subprocess<'ignore', 'pipe', 'pipe'> | null = null
|
|
160
|
+
private starting: Promise<ComfyPanelUp> | null = null
|
|
161
|
+
private log: string[] = []
|
|
162
|
+
|
|
163
|
+
constructor(private o: { root: string; port: number }) {}
|
|
164
|
+
|
|
165
|
+
get url(): string {
|
|
166
|
+
return `http://127.0.0.1:${this.o.port}`
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async ensure(): Promise<ComfyPanelUp> {
|
|
170
|
+
const want = scanComfyWorkflows(this.o.root)
|
|
171
|
+
if (want.length === 0) throw new Error(`no ${SUFFIX} module under .shipkit/comfy/workflows`)
|
|
172
|
+
const live = await probeIndex(this.url)
|
|
173
|
+
if (live != null) {
|
|
174
|
+
const v = panelIdentity(live, want)
|
|
175
|
+
if (!v.ok) throw new Error(panelRefusal(this.o.port, v))
|
|
176
|
+
await this.requireWebShell()
|
|
177
|
+
return { url: this.url, port: this.o.port }
|
|
178
|
+
}
|
|
179
|
+
if (this.proc != null && this.proc.exitCode != null) this.proc = null
|
|
180
|
+
this.starting ??= this.spawn().finally(() => {
|
|
181
|
+
this.starting = null
|
|
182
|
+
})
|
|
183
|
+
return await this.starting
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
stop(): void {
|
|
187
|
+
this.proc?.kill()
|
|
188
|
+
this.proc = null
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** comfy-ts serve imports the workflow modules at BOOT, so an edited module
|
|
192
|
+
* reaches the panel only through a new process. An ADOPTED panel is the
|
|
193
|
+
* common case and there is no child to kill, so the port holder is killed
|
|
194
|
+
* by pid: without that, ensure() re-adopts the same process and the
|
|
195
|
+
* restart is the no-op the wait below exists to prevent. The wait matters:
|
|
196
|
+
* a killed server still answers for a beat */
|
|
197
|
+
async restart(): Promise<ComfyPanelUp> {
|
|
198
|
+
const dying = this.proc
|
|
199
|
+
this.stop()
|
|
200
|
+
if (dying != null) await Promise.race([dying.exited, Bun.sleep(3000)])
|
|
201
|
+
else await this.killAdopted()
|
|
202
|
+
for (let i = 0; i < QUIET_TRIES && (await probeIndex(this.url, 400)) != null; i++) await Bun.sleep(QUIET_STEP_MS)
|
|
203
|
+
return await this.ensure()
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** the live panel nobody here spawned. Identity is verified AGAIN before
|
|
207
|
+
* anything dies, because a foreign server on that port is another
|
|
208
|
+
* program's process, and a port nothing listens on is said out loud
|
|
209
|
+
* rather than restarted into the same old panel */
|
|
210
|
+
private async killAdopted(): Promise<void> {
|
|
211
|
+
const live = await probeIndex(this.url)
|
|
212
|
+
if (live == null) return
|
|
213
|
+
const v = panelIdentity(live, scanComfyWorkflows(this.o.root))
|
|
214
|
+
if (!v.ok && v.kind === 'foreign') throw new Error(panelRefusal(this.o.port, v))
|
|
215
|
+
const pids = portHolders(this.o.port).filter((pid) => pid !== process.pid)
|
|
216
|
+
if (pids.length === 0)
|
|
217
|
+
throw new Error(
|
|
218
|
+
`the panel on port ${this.o.port} cannot be restarted: no other process is listening there, so nothing to kill. Kill it by hand (lsof -ti tcp:${this.o.port} | xargs kill) and open the panel again`,
|
|
219
|
+
)
|
|
220
|
+
for (const pid of pids) kill(pid, 'SIGTERM')
|
|
221
|
+
for (let i = 0; i < KILL_TRIES && portHolders(this.o.port).some((p) => pids.includes(p)); i++) await Bun.sleep(KILL_STEP_MS)
|
|
222
|
+
for (const pid of portHolders(this.o.port).filter((p) => pids.includes(p))) kill(pid, 'SIGKILL')
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** nothing is mounted on a panel that cannot answer a browser: the frame
|
|
226
|
+
* would show the json index and the chip would sit on starting forever */
|
|
227
|
+
private async requireWebShell(): Promise<void> {
|
|
228
|
+
if (await probeWebShell(this.url)) return
|
|
229
|
+
throw new Error(noWebPanelRefusal(installedComfyTsVersion(this.o.root)))
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private tail(): string {
|
|
233
|
+
return this.log.length === 0 ? '(it printed nothing)' : this.log.join('\n')
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private async spawn(): Promise<ComfyPanelUp> {
|
|
237
|
+
// the REPO's own copy: a workflow module imports the bare 'comfy-ts'
|
|
238
|
+
// specifier, so the engine running it must be the one it resolves
|
|
239
|
+
const bin = join(this.o.root, 'node_modules', '.bin', 'comfy-ts')
|
|
240
|
+
if (!existsSync(bin)) throw new Error(`comfy-ts is not installed in this repo — run: bun add comfy-ts`)
|
|
241
|
+
this.log = []
|
|
242
|
+
// process.execPath is bun: a .cflow.ts module is TypeScript and _host.ts
|
|
243
|
+
// reads import.meta.dir, so node loads neither
|
|
244
|
+
const proc = Bun.spawn([process.execPath, bin, 'serve', comfyWorkflowsDir(this.o.root), '--port', String(this.o.port)], {
|
|
245
|
+
cwd: this.o.root,
|
|
246
|
+
stdin: 'ignore',
|
|
247
|
+
stdout: 'pipe',
|
|
248
|
+
stderr: 'pipe',
|
|
249
|
+
})
|
|
250
|
+
this.proc = proc
|
|
251
|
+
void this.drain(proc.stdout)
|
|
252
|
+
void this.drain(proc.stderr)
|
|
253
|
+
for (let i = 0; i < BOOT_TRIES; i++) {
|
|
254
|
+
if (proc.exitCode != null) throw new Error(`comfy-ts serve exited (${proc.exitCode}):\n${this.tail()}`)
|
|
255
|
+
const idx = await probeIndex(this.url, 800)
|
|
256
|
+
if (idx != null) {
|
|
257
|
+
// a panel too old to serve a ui is killed rather than left
|
|
258
|
+
// holding the port for the next ensure() to adopt, and the
|
|
259
|
+
// refusal waits for the exit: a killed server still answers
|
|
260
|
+
// for a beat
|
|
261
|
+
await this.requireWebShell().catch(async (e: Error) => {
|
|
262
|
+
proc.kill()
|
|
263
|
+
this.proc = null
|
|
264
|
+
await Promise.race([proc.exited, Bun.sleep(3000)])
|
|
265
|
+
throw e
|
|
266
|
+
})
|
|
267
|
+
return { url: this.url, port: this.o.port }
|
|
268
|
+
}
|
|
269
|
+
await Bun.sleep(BOOT_STEP_MS)
|
|
270
|
+
}
|
|
271
|
+
proc.kill()
|
|
272
|
+
this.proc = null
|
|
273
|
+
throw new Error(`comfy-ts serve never answered on ${this.url}:\n${this.tail()}`)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** the child's output is a startup wall of routes on success and the only
|
|
277
|
+
* account of the failure otherwise, so the tail rides the error message
|
|
278
|
+
* instead of the hub's terminal */
|
|
279
|
+
private async drain(s: ReadableStream<Uint8Array>): Promise<void> {
|
|
280
|
+
const dec = new TextDecoder()
|
|
281
|
+
let buf = ''
|
|
282
|
+
for await (const chunk of s as unknown as AsyncIterable<Uint8Array>) {
|
|
283
|
+
buf += dec.decode(chunk, { stream: true })
|
|
284
|
+
const lines = buf.split('\n')
|
|
285
|
+
buf = lines.pop() ?? ''
|
|
286
|
+
for (const l of lines) {
|
|
287
|
+
this.log.push(l)
|
|
288
|
+
if (this.log.length > LOG_LINES) this.log.shift()
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
// The ONE bridge from the contracts engine into wire output — every surface
|
|
2
2
|
// that reports contract fulfillness (`shipkit check` via rule SK002, the ci
|
|
3
|
-
// `contracts`
|
|
3
|
+
// `contracts` check, `shipkit contracts`) goes through applyContractsResult,
|
|
4
4
|
// so tags, the empty-repo sentence, and severity plumbing exist exactly
|
|
5
5
|
// once. Kinds pass through raw so fix pointers and visible exempts survive
|
|
6
6
|
// every printer.
|
|
7
|
-
import {
|
|
8
|
-
import { join } from 'path'
|
|
9
|
-
import type { CheckCtx } from '../core/check-ctx.ts'
|
|
7
|
+
import type { CheckCtx, ScanCtx } from '../core/check-ctx.ts'
|
|
10
8
|
import type { RepoConfig } from '../repo-config.ts'
|
|
11
|
-
import {
|
|
9
|
+
import { tagCheckLines } from '../check.ts'
|
|
10
|
+
import { defineScan } from '../scan.ts'
|
|
12
11
|
import { allContracts } from './contract-manifests.ts'
|
|
13
12
|
import type { ContractManifest } from './contract-types.ts'
|
|
14
|
-
import type
|
|
13
|
+
import { evaluateContracts, type ContractsConfig, type ContractsResult } from './contracts-engine.ts'
|
|
15
14
|
|
|
16
|
-
export function contractsConfigFrom(c: RepoConfig,
|
|
15
|
+
export function contractsConfigFrom(c: RepoConfig, d: Pick<ScanCtx, 'root' | 'exists'>): ContractsConfig {
|
|
17
16
|
const venues = c.venues ?? []
|
|
18
17
|
const social = c.social ?? []
|
|
19
18
|
const declared = [
|
|
@@ -22,10 +21,10 @@ export function contractsConfigFrom(c: RepoConfig, root: string): ContractsConfi
|
|
|
22
21
|
...(social.includes('discord') ? ['discord'] : []),
|
|
23
22
|
]
|
|
24
23
|
return {
|
|
25
|
-
root,
|
|
24
|
+
root: d.root,
|
|
26
25
|
declared,
|
|
27
26
|
social,
|
|
28
|
-
publicSurface: venues.length > 0 ||
|
|
27
|
+
publicSurface: venues.length > 0 || d.exists('deploy.sh'),
|
|
29
28
|
exempt: c.contracts?.exempt,
|
|
30
29
|
docsBase: c.docsBase,
|
|
31
30
|
}
|
|
@@ -35,13 +34,19 @@ export function activeContracts(c: RepoConfig): ContractManifest[] {
|
|
|
35
34
|
return [...allContracts, ...(c.contracts?.extra ?? [])]
|
|
36
35
|
}
|
|
37
36
|
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
/** ONE evaluation per run, shared by every check that reports fulfillness
|
|
38
|
+
* (SK002 and `contracts`), each of them cached on what it read */
|
|
39
|
+
export const contractsResult = defineScan<ContractsResult>('contracts', (c) =>
|
|
40
|
+
evaluateContracts(activeContracts(c.config), contractsConfigFrom(c.config, c), c),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
export function applyContractsResult(t: CheckCtx, r: ContractsResult, tag?: { id?: string; fix?: unknown }): void {
|
|
44
|
+
const lines = tag?.id != null ? tagCheckLines({ id: tag.id, fix: tag.fix }, r.lines) : r.lines
|
|
40
45
|
for (const l of lines) t.lines.push(l)
|
|
41
46
|
if (r.exitCode === 1) t.failed = true
|
|
42
47
|
if (r.lines.some((l) => l.kind === 'warn')) t.warned = true
|
|
43
48
|
if (r.lines.length === 0) {
|
|
44
49
|
const text = 'no contracts apply (nothing declared, nothing detected)'
|
|
45
|
-
t.ok(tag != null ? `[${tag.id}] ${text}` : text)
|
|
50
|
+
t.ok(tag?.id != null ? `[${tag.id}] ${text}` : text)
|
|
46
51
|
}
|
|
47
52
|
}
|
|
@@ -31,15 +31,27 @@ export type ContractEnv = {
|
|
|
31
31
|
venues: string[]
|
|
32
32
|
social: string[]
|
|
33
33
|
exists: (relPath: string) => boolean
|
|
34
|
+
/** the text of a repo-relative file, null when none. A probe reads the
|
|
35
|
+
* repo through this and `exists`, so a check reporting it is cached on
|
|
36
|
+
* what the probe looked at: reaching the disk around them is what keeps
|
|
37
|
+
* that check uncached (core/raw-access.ts) */
|
|
38
|
+
read: (relPath: string) => string | null
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
export type ContractManifest = {
|
|
37
|
-
|
|
42
|
+
/** kebab id of the venue, and the contract folder is `.shipkit/<venue>/` */
|
|
43
|
+
venue: string
|
|
44
|
+
/** the contract folder, `.shipkit/<venue>`, checked against the venue id */
|
|
38
45
|
dir: string
|
|
39
|
-
|
|
46
|
+
/** docs path suffix, rendered after the consumer-configured docsBase in fix: lines */
|
|
47
|
+
owner: string
|
|
48
|
+
/** `declared` = required once the repo names this venue. `always` = every repo, behind `gate` */
|
|
40
49
|
appliesTo: 'declared' | 'always'
|
|
41
|
-
|
|
50
|
+
/** `always` contracts only: FAIL once a public surface exists, WARN before */
|
|
51
|
+
gate?: 'public-surface'
|
|
52
|
+
/** signals that this venue is in play although the repo never declared it, so config cannot lie */
|
|
42
53
|
detect: Detector[]
|
|
54
|
+
/** the files the folder must carry. A contract with an empty list is prose */
|
|
43
55
|
artifacts: ArtifactRule[]
|
|
44
56
|
}
|
|
45
57
|
|
|
@@ -57,7 +69,7 @@ export const dirExists = (path: string): Detector => ({ kind: 'dirExists', path
|
|
|
57
69
|
export function validateManifest(m: ContractManifest): string[] {
|
|
58
70
|
const errs: string[] = []
|
|
59
71
|
if (!/^[a-z0-9-]+$/.test(m.venue)) errs.push(`venue '${m.venue}' must be kebab-case [a-z0-9-]`)
|
|
60
|
-
if (m.dir !== `.
|
|
72
|
+
if (m.dir !== `.shipkit/${m.venue}`) errs.push(`dir '${m.dir}' must be '.shipkit/${m.venue}' (folder naming rule)`)
|
|
61
73
|
if (m.artifacts.length === 0) errs.push('artifacts must not be empty — a contract with nothing to check is prose, not a contract')
|
|
62
74
|
if (m.appliesTo === 'declared' && m.detect.length === 0) errs.push(`appliesTo 'declared' needs >=1 detector — otherwise an undeclared venue can never be caught`)
|
|
63
75
|
if (m.gate != null && m.appliesTo !== 'always') errs.push(`gate '${m.gate}' only applies to 'always' contracts`)
|
|
@@ -7,11 +7,16 @@
|
|
|
7
7
|
// - 'always' contracts with gate 'public-surface': full severity once the
|
|
8
8
|
// repo has a public surface, everything demoted to WARN before.
|
|
9
9
|
// Every FAIL carries a fix: pointer = docsBase + manifest.owner.
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
//
|
|
11
|
+
// It reads ONLY through the disk it is handed (a check context), so what a
|
|
12
|
+
// verdict rests on is recorded, and a check reporting it is cached on that.
|
|
13
|
+
import type { ScanCtx } from '../core/check-ctx.ts'
|
|
12
14
|
import type { ArtifactRule, ContractEnv, ContractManifest } from './contract-types.ts'
|
|
13
15
|
import type { Line } from '../core/wire.ts'
|
|
14
16
|
|
|
17
|
+
/** what the engine reads through: a check context, or a shared scan's */
|
|
18
|
+
export type ContractDisk = Pick<ScanCtx, 'read' | 'bytes' | 'exists' | 'entries'>
|
|
19
|
+
|
|
15
20
|
export type ContractsConfig = {
|
|
16
21
|
root: string
|
|
17
22
|
/** venue ids the repo declares it ships on (config side of both-ways drift) */
|
|
@@ -31,35 +36,67 @@ export type ContractsResult = { lines: Line[]; exitCode: 0 | 1; outcomes: Contra
|
|
|
31
36
|
|
|
32
37
|
// PNG dims from the IHDR header — bytes 16..23 big-endian, after the
|
|
33
38
|
// 8-byte signature + IHDR length/type. No image dependency.
|
|
34
|
-
export function pngDims(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
export function pngDims(bytes: Uint8Array | null): { width: number; height: number } | null {
|
|
40
|
+
if (bytes == null || bytes.length < 24) return null
|
|
41
|
+
const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
|
|
42
|
+
for (let i = 0; i < sig.length; i++) if (bytes[i] !== sig[i]) return null
|
|
43
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
44
|
+
return { width: view.getUint32(16), height: view.getUint32(20) }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const WILD = /[*?[{]/
|
|
48
|
+
|
|
49
|
+
/** the files matching a repo-relative glob, walked one dir at a time through
|
|
50
|
+
* `entries`, so a folder git ignores is still seen and each dir read is
|
|
51
|
+
* recorded. A hidden name matches only a segment that starts with a dot, and
|
|
52
|
+
* `**` never walks into a hidden dir */
|
|
53
|
+
export function globFiles(pattern: string, d: ContractDisk): string[] {
|
|
54
|
+
const segs = pattern.split('/').filter((x) => x !== '' && x !== '.')
|
|
55
|
+
const out = new Set<string>()
|
|
56
|
+
const at = (dir: string, name: string): string => (dir === '' ? name : `${dir}/${name}`)
|
|
57
|
+
const inside = (dir: string) => d.entries(dir === '' ? '.' : dir)
|
|
58
|
+
const walk = (dir: string, i: number): void => {
|
|
59
|
+
const seg = segs[i]
|
|
60
|
+
if (seg == null) return
|
|
61
|
+
const last = i === segs.length - 1
|
|
62
|
+
if (seg === '**') {
|
|
63
|
+
walk(dir, i + 1)
|
|
64
|
+
for (const e of inside(dir)) if (e.kind === 'dir' && !e.name.startsWith('.')) walk(at(dir, e.name), i)
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
if (!WILD.test(seg)) {
|
|
68
|
+
if (!last) walk(at(dir, seg), i + 1)
|
|
69
|
+
else if (d.exists(at(dir, seg), 'file')) out.add(at(dir, seg))
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
const g = new Bun.Glob(seg)
|
|
73
|
+
for (const e of inside(dir)) {
|
|
74
|
+
if (e.name.startsWith('.') && !seg.startsWith('.')) continue
|
|
75
|
+
if (!g.match(e.name)) continue
|
|
76
|
+
if (!last) { if (e.kind === 'dir') walk(at(dir, e.name), i + 1) }
|
|
77
|
+
else if (e.kind === 'file') out.add(at(dir, e.name))
|
|
78
|
+
}
|
|
43
79
|
}
|
|
80
|
+
walk('', 0)
|
|
81
|
+
return [...out].sort()
|
|
44
82
|
}
|
|
45
83
|
|
|
46
|
-
|
|
84
|
+
/** the text of a file, null when no FILE stands there */
|
|
85
|
+
const textOf = (d: ContractDisk, rel: string): string | null => (d.exists(rel, 'file') ? d.read(rel) : null)
|
|
47
86
|
|
|
48
|
-
async function checkRule(rule: ArtifactRule, env: ContractEnv): Promise<string[]> {
|
|
87
|
+
async function checkRule(rule: ArtifactRule, env: ContractEnv, d: ContractDisk): Promise<string[]> {
|
|
49
88
|
const problems: string[] = []
|
|
50
89
|
switch (rule.kind) {
|
|
51
90
|
case 'file': {
|
|
52
|
-
|
|
53
|
-
if (!
|
|
54
|
-
else if (
|
|
55
|
-
else if (rule.nonEmpty && statSync(p).size === 0) problems.push(`${rule.path} is empty`)
|
|
91
|
+
if (!d.exists(rule.path)) problems.push(`${rule.path} missing`)
|
|
92
|
+
else if (!d.exists(rule.path, 'file')) problems.push(`${rule.path} is not a file`)
|
|
93
|
+
else if (rule.nonEmpty && (d.bytes(rule.path)?.length ?? 0) === 0) problems.push(`${rule.path} is empty`)
|
|
56
94
|
break
|
|
57
95
|
}
|
|
58
96
|
case 'png': {
|
|
59
|
-
const p = abs(env.root, rule.path)
|
|
60
97
|
const need = rule.atLeast === true ? `>= ${rule.width}x${rule.height} at ${rule.width}:${rule.height}` : `${rule.width}x${rule.height}`
|
|
61
|
-
if (!
|
|
62
|
-
const dims = pngDims(
|
|
98
|
+
if (!d.exists(rule.path)) { problems.push(`${rule.path} missing — need png ${need}`); break }
|
|
99
|
+
const dims = pngDims(d.exists(rule.path, 'file') ? d.bytes(rule.path) : null)
|
|
63
100
|
if (dims == null) problems.push(`${rule.path} is not a readable png`)
|
|
64
101
|
else if (rule.atLeast !== true) {
|
|
65
102
|
if (dims.width !== rule.width || dims.height !== rule.height) problems.push(`${rule.path} is ${dims.width}x${dims.height} — need ${need}`)
|
|
@@ -75,27 +112,25 @@ async function checkRule(rule: ArtifactRule, env: ContractEnv): Promise<string[]
|
|
|
75
112
|
break
|
|
76
113
|
}
|
|
77
114
|
case 'glob': {
|
|
78
|
-
const matches =
|
|
115
|
+
const matches = globFiles(rule.pattern, d)
|
|
79
116
|
if (matches.length < rule.min) { problems.push(`${rule.pattern}: ${matches.length} file(s) — need >= ${rule.min}`); break }
|
|
80
117
|
if (rule.width != null && rule.height != null)
|
|
81
118
|
for (const m of matches) {
|
|
82
|
-
const dims = pngDims(
|
|
119
|
+
const dims = pngDims(d.bytes(m))
|
|
83
120
|
if (dims == null || dims.width !== rule.width || dims.height !== rule.height)
|
|
84
121
|
problems.push(`${m} is ${dims == null ? 'not a readable png' : `${dims.width}x${dims.height}`} — need ${rule.width}x${rule.height}`)
|
|
85
122
|
}
|
|
86
123
|
break
|
|
87
124
|
}
|
|
88
125
|
case 'md': {
|
|
89
|
-
const
|
|
90
|
-
if (
|
|
91
|
-
const text = readFileSync(p, 'utf8')
|
|
126
|
+
const text = textOf(d, rule.path)
|
|
127
|
+
if (text == null) { problems.push(`${rule.path} missing`); break }
|
|
92
128
|
if (text.trim() === '') { problems.push(`${rule.path} is empty`); break }
|
|
93
129
|
for (const needle of rule.mustContain) if (!text.includes(needle)) problems.push(`${rule.path} lacks required '${needle}'`)
|
|
94
130
|
break
|
|
95
131
|
}
|
|
96
132
|
case 'dir': {
|
|
97
|
-
|
|
98
|
-
if (!existsSync(p) || !statSync(p).isDirectory()) problems.push(`${rule.path}/ missing`)
|
|
133
|
+
if (!d.exists(rule.path, 'dir')) problems.push(`${rule.path}/ missing`)
|
|
99
134
|
break
|
|
100
135
|
}
|
|
101
136
|
case 'custom': {
|
|
@@ -106,19 +141,19 @@ async function checkRule(rule: ArtifactRule, env: ContractEnv): Promise<string[]
|
|
|
106
141
|
return problems
|
|
107
142
|
}
|
|
108
143
|
|
|
109
|
-
function detectorFires(m: ContractManifest,
|
|
110
|
-
for (const
|
|
111
|
-
if (
|
|
112
|
-
if (
|
|
113
|
-
} else if (
|
|
114
|
-
const
|
|
115
|
-
if (
|
|
144
|
+
function detectorFires(m: ContractManifest, d: ContractDisk): string | null {
|
|
145
|
+
for (const det of m.detect) {
|
|
146
|
+
if (det.kind === 'dirExists') {
|
|
147
|
+
if (d.exists(det.path)) return `${det.path}/ exists`
|
|
148
|
+
} else if (det.kind === 'fileContains') {
|
|
149
|
+
const text = textOf(d, det.path)
|
|
150
|
+
if (text != null && new RegExp(det.pattern).test(text)) return `${det.path} matches /${det.pattern}/`
|
|
116
151
|
} else {
|
|
117
|
-
const
|
|
118
|
-
if (
|
|
152
|
+
const pkg = textOf(d, 'package.json')
|
|
153
|
+
if (pkg == null) continue
|
|
119
154
|
try {
|
|
120
|
-
const scripts = (JSON.parse(
|
|
121
|
-
const re = new RegExp(
|
|
155
|
+
const scripts = (JSON.parse(pkg) as { scripts?: Record<string, string> }).scripts ?? {}
|
|
156
|
+
const re = new RegExp(det.pattern)
|
|
122
157
|
const hit = Object.keys(scripts).find((k) => re.test(k))
|
|
123
158
|
if (hit != null) return `package.json script '${hit}'`
|
|
124
159
|
} catch { /* unparseable package.json is another tool's FAIL */ }
|
|
@@ -127,7 +162,7 @@ function detectorFires(m: ContractManifest, cfg: ContractsConfig): string | null
|
|
|
127
162
|
return null
|
|
128
163
|
}
|
|
129
164
|
|
|
130
|
-
export async function evaluateContracts(manifests: ContractManifest[], cfg: ContractsConfig): Promise<ContractsResult> {
|
|
165
|
+
export async function evaluateContracts(manifests: ContractManifest[], cfg: ContractsConfig, d: ContractDisk): Promise<ContractsResult> {
|
|
131
166
|
const lines: Line[] = []
|
|
132
167
|
const outcomes: ContractOutcome[] = []
|
|
133
168
|
let anyFail = false
|
|
@@ -135,7 +170,8 @@ export async function evaluateContracts(manifests: ContractManifest[], cfg: Cont
|
|
|
135
170
|
root: cfg.root,
|
|
136
171
|
venues: cfg.declared,
|
|
137
172
|
social: cfg.social,
|
|
138
|
-
exists: (rel) =>
|
|
173
|
+
exists: (rel) => d.exists(rel),
|
|
174
|
+
read: (rel) => textOf(d, rel),
|
|
139
175
|
}
|
|
140
176
|
const fix = (m: ContractManifest, text: string): Line[] => [
|
|
141
177
|
{ kind: 'fix-fail', text: `${text} — ${(cfg.docsBase ?? '') + m.owner}` },
|
|
@@ -150,7 +186,7 @@ export async function evaluateContracts(manifests: ContractManifest[], cfg: Cont
|
|
|
150
186
|
}
|
|
151
187
|
const declared = cfg.declared.includes(m.venue)
|
|
152
188
|
if (m.appliesTo === 'declared' && !declared) {
|
|
153
|
-
const signal = detectorFires(m,
|
|
189
|
+
const signal = detectorFires(m, d)
|
|
154
190
|
if (signal != null) {
|
|
155
191
|
anyFail = true
|
|
156
192
|
lines.push({ kind: 'fail', text: `${m.venue} ships (${signal}) but venue is not declared — declare it or remove the signal` })
|
|
@@ -166,7 +202,7 @@ export async function evaluateContracts(manifests: ContractManifest[], cfg: Cont
|
|
|
166
202
|
let problems = 0
|
|
167
203
|
let warned = 0
|
|
168
204
|
for (const rule of m.artifacts) {
|
|
169
|
-
const found = await checkRule(rule, env)
|
|
205
|
+
const found = await checkRule(rule, env, d)
|
|
170
206
|
const asWarn = warnAll || rule.warnOnly === true
|
|
171
207
|
for (const p of found) {
|
|
172
208
|
if (asWarn) {
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
// normative WHAT for .
|
|
2
|
-
// contract. Posts stay in .
|
|
1
|
+
// normative WHAT for .shipkit/discord/ — a project's public Discord server
|
|
2
|
+
// contract. Posts stay in .shipkit/social/.
|
|
3
3
|
import { defineContract, dirExists, md, png } from 'rvlib-shipkit/src/contracts/contract-types.ts'
|
|
4
4
|
|
|
5
5
|
export default defineContract({
|
|
6
6
|
venue: 'discord',
|
|
7
|
-
dir: '.
|
|
7
|
+
dir: '.shipkit/discord',
|
|
8
8
|
owner: 'distribute/post-publicly.md',
|
|
9
9
|
appliesTo: 'declared',
|
|
10
|
-
detect: [dirExists('.
|
|
10
|
+
detect: [dirExists('.shipkit/discord')],
|
|
11
11
|
artifacts: [
|
|
12
|
-
md('.
|
|
13
|
-
png('.
|
|
12
|
+
md('.shipkit/discord/server.md', { mustContain: ['invite'] }),
|
|
13
|
+
png('.shipkit/discord/icon.png', 512, 512, { atLeast: true }),
|
|
14
14
|
],
|
|
15
15
|
})
|
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
// normative WHAT for .
|
|
1
|
+
// normative WHAT for .shipkit/identity/ — every repo with a public surface owns
|
|
2
2
|
// its brand source + committed favicon/logo/OG outputs. gate: FAIL once a
|
|
3
3
|
// public surface exists (deploy.sh or any declared venue), WARN before.
|
|
4
4
|
import { defineContract, file, glob, png } from 'rvlib-shipkit/src/contracts/contract-types.ts'
|
|
5
5
|
|
|
6
6
|
export default defineContract({
|
|
7
7
|
venue: 'identity',
|
|
8
|
-
dir: '.
|
|
8
|
+
dir: '.shipkit/identity',
|
|
9
9
|
owner: 'build/generate-assets.md',
|
|
10
10
|
appliesTo: 'always',
|
|
11
11
|
gate: 'public-surface',
|
|
12
12
|
detect: [],
|
|
13
13
|
artifacts: [
|
|
14
|
-
file('.
|
|
14
|
+
file('.shipkit/identity/brand.ts'),
|
|
15
15
|
png('public/favicon.png', 512, 512),
|
|
16
16
|
png('public/og.png', 1200, 630, { atLeast: true }),
|
|
17
17
|
glob('public/logo.*', { min: 1 }),
|