rvlib-shipkit 0.38.1 → 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 +367 -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 +209 -15
- 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
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// valid package.json. Only the NAME betrayed it.
|
|
8
8
|
import { existsSync } from 'fs'
|
|
9
9
|
import { join } from 'path'
|
|
10
|
+
import { gitBin } from '../core/git-bin.ts'
|
|
10
11
|
|
|
11
12
|
export type IdentityOutcome =
|
|
12
13
|
| { status: 'skipped'; reason: string }
|
|
@@ -18,7 +19,7 @@ export type IdentityOutcome =
|
|
|
18
19
|
const SCRATCH = /\.(bak|orig|rej)$/
|
|
19
20
|
|
|
20
21
|
const git = (root: string, args: string[]): { code: number; out: string } => {
|
|
21
|
-
const p = Bun.spawnSync([
|
|
22
|
+
const p = Bun.spawnSync([gitBin(), ...args], { cwd: root, env: process.env })
|
|
22
23
|
return { code: p.exitCode, out: p.stdout.toString() }
|
|
23
24
|
}
|
|
24
25
|
|
package/src/hooks/run-hooks.ts
CHANGED
|
@@ -6,31 +6,44 @@
|
|
|
6
6
|
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'fs'
|
|
7
7
|
import { dirname, join } from 'path'
|
|
8
8
|
import { fileURLToPath } from 'url'
|
|
9
|
+
import { KEYWORD_FILE, LAYOUT } from '../core/layout.ts'
|
|
9
10
|
import { commitMessageOffenses } from './commit-message.ts'
|
|
10
11
|
import { globalAllowFile, globalKeywordFile, maskPattern, patternFingerprint, runLeakGuard, type LeakAllowEntry, type LeakHit, type LeakWaiver } from './leak-guard.ts'
|
|
11
12
|
import { runRootIdentity } from './root-identity.ts'
|
|
13
|
+
import { gitRoot } from '../core/git-root.ts'
|
|
12
14
|
import { spawnWithTimeout } from '../core/spawn-timeout.ts'
|
|
13
15
|
import type { RepoConfig } from '../repo-config.ts'
|
|
16
|
+
import { gitBin } from '../core/git-bin.ts'
|
|
14
17
|
|
|
15
18
|
export type HooksConfig = {
|
|
16
19
|
/** repo-specific commands run in the pre-commit gate, after the built-ins.
|
|
17
|
-
*
|
|
18
|
-
preCommit?: {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
* A non-zero exit blocks the commit, and the output is shown. */
|
|
21
|
+
preCommit?: {
|
|
22
|
+
/** gate name, printed by `shipkit hooks list` */
|
|
23
|
+
id: string
|
|
24
|
+
/** argv, run without a shell */
|
|
25
|
+
cmd: string[]
|
|
26
|
+
}[]
|
|
27
|
+
/** repo-specific commands run on the commit message. The message file path
|
|
28
|
+
* arrives as the last argument. */
|
|
29
|
+
commitMsg?: {
|
|
30
|
+
/** gate name, printed by `shipkit hooks list` */
|
|
31
|
+
id: string
|
|
32
|
+
/** argv, with the message file path appended */
|
|
33
|
+
cmd: string[]
|
|
34
|
+
}[]
|
|
35
|
+
/** keyword file for the leak guard (default .shipkit/private/banned-keywords.txt) */
|
|
23
36
|
keywordFile?: string
|
|
24
37
|
/** known-good leak-guard matches this repo waives: a lockfile whose
|
|
25
38
|
* dependency names or integrity hashes collide with a keyword is the
|
|
26
39
|
* motivating case. Each entry carries a reason, and a waiver that fires
|
|
27
40
|
* PRINTS, so nothing passes silently. This file is tracked, so an entry
|
|
28
41
|
* names a pattern by fingerprint (`fp:…`, printed beside every hit) and
|
|
29
|
-
* never by value. The machine-global twin is ~/.
|
|
42
|
+
* never by value. The machine-global twin is ~/.shipkit/private/leak-allow.json.
|
|
30
43
|
* hooks/leak-guard.ts */
|
|
31
44
|
leakAllow?: LeakAllowEntry[]
|
|
32
|
-
/** staged-path prefixes that
|
|
33
|
-
* '.comfy-ts/'). gitignore is advisory and editable
|
|
45
|
+
/** staged-path prefixes that can never enter git (local state like
|
|
46
|
+
* '.comfy-ts/'). A gitignore line is advisory and editable. This gate is not. */
|
|
34
47
|
blockedPaths?: string[]
|
|
35
48
|
/** words the commit-message language gate never counts as French
|
|
36
49
|
* (proper nouns that carry accents without being a language choice) */
|
|
@@ -41,7 +54,7 @@ export type HooksConfig = {
|
|
|
41
54
|
disable?: ('leak-guard' | 'commit-message' | 'root-identity' | 'commit-language')[]
|
|
42
55
|
}
|
|
43
56
|
|
|
44
|
-
const HOOK_DIR =
|
|
57
|
+
const HOOK_DIR = LAYOUT.hooks
|
|
45
58
|
const HOOKS = ['pre-commit', 'commit-msg'] as const
|
|
46
59
|
|
|
47
60
|
/** how a self-written hook body should invoke shipkit in this repo: the
|
|
@@ -66,15 +79,14 @@ export type ArmHooksResult = { state: 'armed' | 'current' | 'foreign' | 'no-git'
|
|
|
66
79
|
* arm (a subdir would set the parent's hooksPath onto a dir that does not
|
|
67
80
|
* exist there, disarming the parent's gates). */
|
|
68
81
|
export function armHooks(root: string, config: RepoConfig): ArmHooksResult {
|
|
69
|
-
const
|
|
70
|
-
if (
|
|
71
|
-
const topDir = top.stdout.toString().trim()
|
|
82
|
+
const topDir = gitRoot(root)?.top
|
|
83
|
+
if (topDir == null) return { state: 'no-git', detail: 'not a git repo' }
|
|
72
84
|
try {
|
|
73
85
|
if (realpathSync(topDir) !== realpathSync(root)) return { state: 'not-root', detail: `nested in ${topDir}, which owns its own commit gates` }
|
|
74
86
|
} catch {
|
|
75
87
|
return { state: 'no-git', detail: 'work tree root unresolvable' }
|
|
76
88
|
}
|
|
77
|
-
const p = Bun.spawnSync([
|
|
89
|
+
const p = Bun.spawnSync([gitBin(), 'config', 'core.hooksPath'], { cwd: root })
|
|
78
90
|
const current = p.exitCode === 0 ? p.stdout.toString().trim() : ''
|
|
79
91
|
if (current !== '' && current !== HOOK_DIR) return { state: 'foreign', detail: `core.hooksPath=${current}, foreign, left alone` }
|
|
80
92
|
if (current === HOOK_DIR && HOOKS.every((h) => existsSync(join(root, HOOK_DIR, h)))) return { state: 'current', detail: 'hooks already armed' }
|
|
@@ -94,7 +106,7 @@ function setHooksPath(root: string): { code: number; detail: string } {
|
|
|
94
106
|
let last = { code: -1, detail: '' }
|
|
95
107
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
96
108
|
if (attempt > 0) Bun.sleepSync(20 * 2 ** (attempt - 1))
|
|
97
|
-
const p = Bun.spawnSync([
|
|
109
|
+
const p = Bun.spawnSync([gitBin(), 'config', 'core.hooksPath', HOOK_DIR], { cwd: root })
|
|
98
110
|
if (p.exitCode === 0) return { code: 0, detail: '' }
|
|
99
111
|
last = { code: p.exitCode, detail: p.stderr.toString().trim().split('\n')[0]?.slice(0, 120) ?? '' }
|
|
100
112
|
// only lock contention is worth retrying; anything else is a real
|
|
@@ -129,12 +141,12 @@ export function installHooks(root: string, binInvocation: string, keywordFile?:
|
|
|
129
141
|
|
|
130
142
|
// a starter so the leak guard bites from day one instead of skipping forever:
|
|
131
143
|
// generic secret shapes only. private names live in the machine-global
|
|
132
|
-
// ~/.
|
|
144
|
+
// ~/.shipkit/private/banned-keywords.txt, which the guard reads on every run
|
|
133
145
|
// (never copied into a repo: a copy rots and a copy can leak)
|
|
134
146
|
const KEYWORD_STARTER = `# banned-keywords — the leak guard greps every commit (added lines, paths,
|
|
135
147
|
# message) for these rows. one row = one case-insensitive substring, or
|
|
136
148
|
# re:<regex>. add repo-specific names here; machine-wide private names go in
|
|
137
|
-
# ~/.
|
|
149
|
+
# ~/.shipkit/private/banned-keywords.txt, read automatically alongside this file.
|
|
138
150
|
# this file is gitignored on purpose: the list itself is private.
|
|
139
151
|
re:-----BEGIN [A-Z ]*PRIVATE KEY-----
|
|
140
152
|
re:AKIA[0-9A-Z]{16}
|
|
@@ -146,7 +158,7 @@ re:xox[baprs]-[A-Za-z0-9-]{10,}
|
|
|
146
158
|
/** seed the configured keyword file with the generic starter so the guard is
|
|
147
159
|
* ARMED, not a skip. an existing file is never touched. */
|
|
148
160
|
function seedKeywordFile(root: string, keywordFile?: string): string | null {
|
|
149
|
-
const rel = keywordFile ??
|
|
161
|
+
const rel = keywordFile ?? KEYWORD_FILE
|
|
150
162
|
const dest = join(root, rel)
|
|
151
163
|
if (existsSync(dest)) return null
|
|
152
164
|
mkdirSync(dirname(dest), { recursive: true })
|
|
@@ -212,7 +224,7 @@ export async function runPreCommit(root: string, config: RepoConfig): Promise<Ga
|
|
|
212
224
|
})
|
|
213
225
|
}
|
|
214
226
|
if ((h.blockedPaths ?? []).length > 0) {
|
|
215
|
-
const names = Bun.spawnSync([
|
|
227
|
+
const names = Bun.spawnSync([gitBin(), 'diff', '--cached', '--name-only'], { cwd: root })
|
|
216
228
|
const staged = names.stdout.toString().split('\n').filter(Boolean)
|
|
217
229
|
const blocked = staged.filter((f) => h.blockedPaths!.some((p) => f.startsWith(p)))
|
|
218
230
|
out.push(blocked.length === 0
|
|
@@ -305,16 +317,16 @@ export async function runCommitMsg(root: string, config: RepoConfig, messageFile
|
|
|
305
317
|
export function listGates(config: RepoConfig, root = process.cwd()): string[] {
|
|
306
318
|
const h = config.hooks ?? {}
|
|
307
319
|
const off = new Set(h.disable ?? [])
|
|
308
|
-
const kw = h.keywordFile ??
|
|
320
|
+
const kw = h.keywordFile ?? KEYWORD_FILE
|
|
309
321
|
// a gate listed as active but unable to fire is worse than no listing:
|
|
310
322
|
// this surface exists so nobody has to guess
|
|
311
|
-
const leakState = off.has('leak-guard') ? ' (DISABLED by config)' : existsSync(join(root, kw)) || globalKeywordFile() != null ? '' : ` (INACTIVE, ${kw} absent and no ~/.
|
|
323
|
+
const leakState = off.has('leak-guard') ? ' (DISABLED by config)' : existsSync(join(root, kw)) || globalKeywordFile() != null ? '' : ` (INACTIVE, ${kw} absent and no ~/.shipkit/private list)`
|
|
312
324
|
// a waiver nobody can enumerate is a hole nobody can audit: the count of
|
|
313
325
|
// declared exemptions belongs on the same surface as the gate itself
|
|
314
326
|
const waivers = (h.leakAllow ?? []).length + (globalAllowFile() != null ? 1 : 0)
|
|
315
|
-
const leakAllowState = waivers === 0 ? '' : `, ${(h.leakAllow ?? []).length} repo waiver(s)${globalAllowFile() == null ? '' : ' + ~/.
|
|
327
|
+
const leakAllowState = waivers === 0 ? '' : `, ${(h.leakAllow ?? []).length} repo waiver(s)${globalAllowFile() == null ? '' : ' + ~/.shipkit/private/leak-allow.json'}`
|
|
316
328
|
return [
|
|
317
|
-
`pre-commit leak-guard${leakState} — staged added lines + paths vs ${kw} + ~/.
|
|
329
|
+
`pre-commit leak-guard${leakState} — staged added lines + paths vs ${kw} + ~/.shipkit/private/banned-keywords.txt${leakAllowState}`,
|
|
318
330
|
...((h.blockedPaths ?? []).length > 0 ? [`pre-commit blocked-paths — nothing under ${h.blockedPaths!.join(', ')} enters git`] : []),
|
|
319
331
|
`pre-commit root-identity${off.has('root-identity') ? ' (DISABLED by config)' : ''} — the repo's own name in package.json + repo.config.ts, and scratch files`,
|
|
320
332
|
...(h.preCommit ?? []).map((c) => `pre-commit ${c.id} — ${c.cmd.join(' ')}`),
|
package/src/init-repo.ts
CHANGED
|
@@ -9,12 +9,15 @@
|
|
|
9
9
|
import { existsSync, readFileSync, writeFileSync } from 'fs'
|
|
10
10
|
import { basename, join } from 'path'
|
|
11
11
|
import { canonicalBodies } from './core/canonical-scripts.ts'
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
12
|
+
import { CheckCtx } from './core/check-ctx.ts'
|
|
13
|
+
import { hasTestFiles, packageJsonShape } from './checks/SK001-package-json-shape.check.ts'
|
|
14
|
+
import { oneWayInPolicy } from './checks/SK013-one-way-in-policy.check.ts'
|
|
15
|
+
import { listRepoFiles } from './core/floor.ts'
|
|
14
16
|
import { reportToMotherhome } from './core/motherhome.ts'
|
|
15
17
|
import { ensureGitRepo, publishRepo, type Run } from './git-bootstrap.ts'
|
|
16
18
|
import { defineRepo, loadRepoConfig, resolveFloor } from './repo-config.ts'
|
|
17
|
-
import { gitignorePolicy } from './
|
|
19
|
+
import { gitignorePolicy } from './checks/SK004-gitignore-policy.check.ts'
|
|
20
|
+
import { vscodeHubTask } from './checks/SK017-vscode-hub-task.check.ts'
|
|
18
21
|
import { editorErgonomics } from './scaffold/project-scaffold.ts'
|
|
19
22
|
import { defaultHubPort } from './serve/hub-port.ts'
|
|
20
23
|
|
|
@@ -26,21 +29,23 @@ const CONFIG_TEMPLATE = (name: string): string => `import { defineRepo } from 'r
|
|
|
26
29
|
|
|
27
30
|
export default defineRepo({
|
|
28
31
|
name: '${name}',
|
|
29
|
-
venues: [], // e.g. ['itch', 'steam'], selects which .
|
|
30
|
-
social: [], // e.g. ['x', 'discord'], the .
|
|
32
|
+
venues: [], // e.g. ['itch', 'steam'], selects which .shipkit/<venue>/ contracts are REQUIRED
|
|
33
|
+
social: [], // e.g. ['x', 'discord'], the .shipkit/social/<channel>/ post folders (+ .shipkit/discord/)
|
|
31
34
|
tasks: [], // repo commit-gate tasks; deps double as race guards
|
|
32
|
-
// lint, typecheck, imports:check and cycles
|
|
33
|
-
// \`test\`
|
|
35
|
+
// lint, typecheck, imports:check and cycles are cached on the tracked tree.
|
|
36
|
+
// \`test\` runs every time until you say what it reads, because an
|
|
34
37
|
// ignored path (a fixture, a local db, a .env) is not in a tree key:
|
|
35
38
|
// defaults: { test: { inputs: ['tests', 'src', 'package.json'] } },
|
|
36
|
-
//
|
|
37
|
-
// gitignore (SK004) is advisory
|
|
38
|
-
|
|
39
|
+
// staged paths that may never enter git: local state, big derived output, a
|
|
40
|
+
// dump naming something private. gitignore (SK004) is advisory and editable,
|
|
41
|
+
// this pre-commit gate is not
|
|
42
|
+
hooks: { blockedPaths: ['.comfy-ts/hosts/', '.comfy-ts/cache/', '.comfy-ts/outputs/'] },
|
|
39
43
|
// the declared reviewer agent type (rule SK008). Uncomment with YOUR
|
|
40
44
|
// standing brief, then \`shipkit check --fix SK008\` writes the agent file:
|
|
41
45
|
// reviewer: { name: 'my-reviewer', brief: '~/path/to/reviewer-brief.md' },
|
|
42
|
-
//
|
|
43
|
-
// \`shipkit check\`
|
|
46
|
+
// checks need no config. Drop a <name>.check.ts file anywhere, and
|
|
47
|
+
// \`shipkit ci\`, \`shipkit check\` and the hub discover it
|
|
48
|
+
// every key this file accepts, one line each: \`shipkit config-keys\`
|
|
44
49
|
})
|
|
45
50
|
`
|
|
46
51
|
|
|
@@ -95,12 +100,12 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
95
100
|
// its .gitignore policy block (same fixer as `check --fix SK004`),
|
|
96
101
|
// with the repo's own contributed lines honored
|
|
97
102
|
const cfg = await loadRepoConfig(root)
|
|
98
|
-
const g = await gitignorePolicy.fix!({ root, config: cfg })
|
|
103
|
+
const g = await gitignorePolicy.fix!(CheckCtx.open({ root, config: cfg }))
|
|
99
104
|
p(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
|
|
100
105
|
// the repos that need ONE WAY IN most are the ones that already exist,
|
|
101
106
|
// so a re-run REPAIRS rather than only seeding a fresh checkout
|
|
102
107
|
await repairOneWayIn(root, cfg, p)
|
|
103
|
-
furnishEditor(root, cfg, p)
|
|
108
|
+
await furnishEditor(root, cfg, p)
|
|
104
109
|
const mh = reportToMotherhome(root, cfg)
|
|
105
110
|
if (mh.note != null) p(mh.note)
|
|
106
111
|
if (wired && opts.noPublish !== true) await publishRepo(root, { github: opts.github, gh: opts.gh, print: p })
|
|
@@ -121,7 +126,7 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
121
126
|
// `test` is scaffolded only once the repo has tests: `bun test` exits
|
|
122
127
|
// non-zero with none, and a scaffold that reds the gate is a bad scaffold
|
|
123
128
|
for (const [k, v] of Object.entries(canonicalBodies({ bin: opts.bin }))) {
|
|
124
|
-
if (k === 'test' && !hasTestFiles(root)) continue
|
|
129
|
+
if (k === 'test' && !hasTestFiles(listRepoFiles(root))) continue
|
|
125
130
|
if (pkg.scripts[k] == null) { pkg.scripts[k] = v; added++ }
|
|
126
131
|
else if (pkg.scripts[k] !== v) p(`skip: package.json script '${k}' exists — left untouched`)
|
|
127
132
|
}
|
|
@@ -131,10 +136,10 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
131
136
|
}
|
|
132
137
|
// the .gitignore policy block (contract git policy) — same fixer as
|
|
133
138
|
// `shipkit check --fix SK004`, one code path
|
|
134
|
-
const g = await gitignorePolicy.fix!({ root, config: defineRepo({ name }) })
|
|
139
|
+
const g = await gitignorePolicy.fix!(CheckCtx.open({ root, config: defineRepo({ name }) }))
|
|
135
140
|
p(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
|
|
136
141
|
await repairOneWayIn(root, defineRepo({ name }), p)
|
|
137
|
-
furnishEditor(root, { name }, p)
|
|
142
|
+
await furnishEditor(root, { name }, p)
|
|
138
143
|
// what the gate actually runs, in ONE line: a thin floor must be seen at
|
|
139
144
|
// init time rather than discovered after a bad commit slipped through.
|
|
140
145
|
// The per-task resolution is diagnostics, and a `skip:` naming a script
|
|
@@ -143,7 +148,7 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
143
148
|
// matter, and reports them about a repo that has code in it
|
|
144
149
|
const floor = resolveFloor(defineRepo({ name }), root)
|
|
145
150
|
if (floor.tasks.length > 0) p(`ok: gate floor: ${floor.tasks.map((t) => t.id).join(', ')}`)
|
|
146
|
-
else p(`WARN the gate would check NOTHING here (${floor.unfilled.map((u) => u.id).join(', ')} all unresolved) — see \`shipkit
|
|
151
|
+
else p(`WARN the gate would check NOTHING here (${floor.unfilled.map((u) => u.id).join(', ')} all unresolved) — see \`shipkit checks\``)
|
|
147
152
|
// every verb comes from the `shipkit` bin — dead on arrival unless the
|
|
148
153
|
// package is actually installed (bunx runs without adding the dep)
|
|
149
154
|
if (opts.bin == null) {
|
|
@@ -174,7 +179,7 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
174
179
|
* files are the only half of that scaffold an adopted repo can be handed
|
|
175
180
|
* unasked, because they change no output of any command (project-scaffold.ts
|
|
176
181
|
* owns the line). Create-only, so a .vscode/ the repo brought with it wins. */
|
|
177
|
-
function furnishEditor(root: string, cfg: { name?: string; serve?: { port?: number } }, p: (line: string) => void): void {
|
|
182
|
+
async function furnishEditor(root: string, cfg: { name?: string; serve?: { port?: number } }, p: (line: string) => void): Promise<void> {
|
|
178
183
|
// the least important thing init does, and it runs AFTER repo.config.ts is
|
|
179
184
|
// on disk: an unwritable .vscode (a read-only tree, a path that is a file),
|
|
180
185
|
// or a config with no name at all, must not abort the run before the
|
|
@@ -183,9 +188,20 @@ function furnishEditor(root: string, cfg: { name?: string; serve?: { port?: numb
|
|
|
183
188
|
try {
|
|
184
189
|
const name = repoName(root, undefined, cfg.name)
|
|
185
190
|
const port = cfg.serve?.port ?? defaultHubPort(name)
|
|
186
|
-
if (editorErgonomics(root, { name, port }).length === 0)
|
|
191
|
+
if (editorErgonomics(root, { name, port }).length === 0) {
|
|
192
|
+
// the repo's own file stays its own, except the hub task, which
|
|
193
|
+
// rule SK017 keeps feeding the problems pane
|
|
194
|
+
// a file SK017 cannot read is still the repo's own: it is never
|
|
195
|
+
// reported as missing, and it is never rewritten
|
|
196
|
+
let hub: { changed: boolean; summary: string }
|
|
197
|
+
try { hub = await vscodeHubTask.fix!(CheckCtx.open({ root, config: defineRepo({ name }) })) } catch (e) {
|
|
198
|
+
p('ok: .vscode/ is this repo\'s own, left untouched')
|
|
199
|
+
return void p(`WARN .vscode/tasks.json does not parse (${e instanceof Error ? e.message : String(e)}): the hub task gets no problem matcher until it does`)
|
|
200
|
+
}
|
|
201
|
+
return void p(hub.changed ? `ok: ${hub.summary}` : 'ok: .vscode/ is this repo\'s own, left untouched')
|
|
202
|
+
}
|
|
187
203
|
p(`ok: .vscode/tasks.json written — opening the folder starts the hub on ${port}`)
|
|
188
|
-
p(' next: add this stack\'s own watchers (dev server, css
|
|
204
|
+
p(' next: add this stack\'s own watchers (dev server, css) — build/vscode-tasks.md carries the shapes')
|
|
189
205
|
} catch (e) {
|
|
190
206
|
p(`WARN no .vscode/tasks.json here: ${e instanceof Error ? e.message : String(e)} — opening the folder starts nothing, everything else landed`)
|
|
191
207
|
}
|
|
@@ -198,8 +214,8 @@ function furnishEditor(root: string, cfg: { name?: string; serve?: { port?: numb
|
|
|
198
214
|
* one that was not. The deny is a RESTRICTION, which is why it lands
|
|
199
215
|
* unconditionally where the scaffold's allow list stays opt in. */
|
|
200
216
|
async function repairOneWayIn(root: string, config: Parameters<typeof defineRepo>[0], p: (line: string) => void): Promise<void> {
|
|
201
|
-
const pkg = await packageJsonShape.fix!({ root, config })
|
|
217
|
+
const pkg = await packageJsonShape.fix!(CheckCtx.open({ root, config }))
|
|
202
218
|
if (pkg.changed) p(`ok: package.json — ${pkg.summary}`)
|
|
203
|
-
const policy = await oneWayInPolicy.fix!({ root, config })
|
|
219
|
+
const policy = await oneWayInPolicy.fix!(CheckCtx.open({ root, config }))
|
|
204
220
|
p(policy.changed ? `ok: ${policy.summary}` : `ok: .claude/settings.json already closes every raw runner`)
|
|
205
221
|
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// What a check costs, per check, from the two things this repo remembers: the
|
|
2
|
+
// store (one file per key, greens only, `at` plus `measuredMs`) and the runs
|
|
3
|
+
// run record beside it (every outcome, red included).
|
|
4
|
+
//
|
|
5
|
+
// The store is HISTORY here and the db is the record. They overlap for as
|
|
6
|
+
// long as both describe the same run, so a stored entry is counted only when
|
|
7
|
+
// it predates that CHECK's first row in the record: otherwise one measurement
|
|
8
|
+
// lands in the average twice, and the sample count, the one number that says
|
|
9
|
+
// whether an average means anything, is the first thing to lie.
|
|
10
|
+
//
|
|
11
|
+
// PER CHECK, never one boundary for the whole repo, because the two sources do
|
|
12
|
+
// not cover the same checks. A discovered check NEVER enters the store: its
|
|
13
|
+
// result lives in the check ttl cache or nowhere at all, so the store holds
|
|
14
|
+
// rules and tasks only, and a surface reading it alone shows every check as
|
|
15
|
+
// never measured however often it runs. The record is the only source that
|
|
16
|
+
// measures a check, and the store is only ever the history of the other two.
|
|
17
|
+
import { readdirSync, readFileSync, statSync } from 'fs'
|
|
18
|
+
import { join } from 'path'
|
|
19
|
+
import type { RunRow, RunStatus } from './run-log.ts'
|
|
20
|
+
import { readRuns, runsPath, runsStamp } from './run-log.ts'
|
|
21
|
+
import { storeRoot } from './store.ts'
|
|
22
|
+
|
|
23
|
+
/** one measurement read back off the store */
|
|
24
|
+
export type StoreSample = { key: string; at: number; ms: number; status: RunStatus }
|
|
25
|
+
|
|
26
|
+
export type CheckLast = { status: RunStatus; at: number; ms: number; replayed: boolean }
|
|
27
|
+
|
|
28
|
+
export type CheckStat = {
|
|
29
|
+
/** the newest outcome, replayed or measured */
|
|
30
|
+
last: CheckLast | null
|
|
31
|
+
/** mean of the MEASURED runs, ms. null when nothing measured it */
|
|
32
|
+
avgMs: number | null
|
|
33
|
+
/** how many measurements the mean is over: an average over two runs is
|
|
34
|
+
* not an average, and the page says which it is looking at */
|
|
35
|
+
samples: number
|
|
36
|
+
slowestMs: number | null
|
|
37
|
+
/** measurements that went red, out of `samples` */
|
|
38
|
+
failures: number
|
|
39
|
+
/** when it was last MEASURED rather than replayed, and what that said:
|
|
40
|
+
* a remembered answer expires from its measurement, never its replay */
|
|
41
|
+
measuredAt?: number | null
|
|
42
|
+
measuredStatus?: RunStatus | null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** the ONE merge. Pure, so the rules above are testable without a disk. */
|
|
46
|
+
export function mergeCheckStats(o: { runs: RunRow[]; store: StoreSample[] }): Map<string, CheckStat> {
|
|
47
|
+
// each check's own first row is its boundary: what the store holds from
|
|
48
|
+
// before it is history the record never saw, and what comes after it is a
|
|
49
|
+
// measurement the record already carries. A check with no rows at all keeps
|
|
50
|
+
// its whole stored history
|
|
51
|
+
const firstRunAt = new Map<string, number>()
|
|
52
|
+
for (const r of o.runs) {
|
|
53
|
+
const prev = firstRunAt.get(r.id)
|
|
54
|
+
if (prev == null || r.at < prev) firstRunAt.set(r.id, r.at)
|
|
55
|
+
}
|
|
56
|
+
type Sample = { at: number; ms: number; status: RunStatus; full?: boolean }
|
|
57
|
+
const samples = new Map<string, Sample[]>()
|
|
58
|
+
const last = new Map<string, CheckLast>()
|
|
59
|
+
const push = (key: string, s: Sample): void => {
|
|
60
|
+
const list = samples.get(key) ?? []
|
|
61
|
+
list.push(s)
|
|
62
|
+
samples.set(key, list)
|
|
63
|
+
}
|
|
64
|
+
for (const s of o.store) {
|
|
65
|
+
if (s.at >= (firstRunAt.get(s.key) ?? Infinity)) continue
|
|
66
|
+
push(s.key, { at: s.at, ms: s.ms, status: s.status })
|
|
67
|
+
const prev = last.get(s.key)
|
|
68
|
+
if (prev == null || s.at > prev.at) last.set(s.key, { status: s.status, at: s.at, ms: s.ms, replayed: false })
|
|
69
|
+
}
|
|
70
|
+
for (const r of o.runs) {
|
|
71
|
+
// a replay measured nothing and a skip ran nothing: both are the last
|
|
72
|
+
// VERDICT and neither is a timing
|
|
73
|
+
if (!r.replayed && r.status !== 'skip') push(r.id, { at: r.at, ms: r.ms, status: r.status, ...(r.full === true ? { full: true } : {}) })
|
|
74
|
+
const prev = last.get(r.id)
|
|
75
|
+
if (prev == null || r.at >= prev.at) last.set(r.id, { status: r.status, at: r.at, ms: r.ms, replayed: r.replayed })
|
|
76
|
+
}
|
|
77
|
+
const out = new Map<string, CheckStat>()
|
|
78
|
+
for (const [key, l] of last) {
|
|
79
|
+
// the quick tier is what every edit waits on: a full tier run (every
|
|
80
|
+
// slow suite, one at a time) joins the numbers only of a check that
|
|
81
|
+
// never ran at the quick one
|
|
82
|
+
const all = samples.get(key) ?? []
|
|
83
|
+
const quick = all.filter((s) => s.full !== true)
|
|
84
|
+
const list = quick.length > 0 ? quick : all
|
|
85
|
+
const newest = list.reduce<Sample | null>((a, s) => (a == null || s.at >= a.at ? s : a), null)
|
|
86
|
+
out.set(key, {
|
|
87
|
+
last: l,
|
|
88
|
+
avgMs: list.length === 0 ? null : Math.round(list.reduce((a, s) => a + s.ms, 0) / list.length),
|
|
89
|
+
samples: list.length,
|
|
90
|
+
slowestMs: list.length === 0 ? null : Math.max(...list.map((s) => s.ms)),
|
|
91
|
+
failures: list.filter((s) => s.status === 'fail').length,
|
|
92
|
+
measuredAt: newest?.at ?? null,
|
|
93
|
+
measuredStatus: newest?.status ?? null,
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
return out
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** the store, read back. A check's file name is the slug of its id
|
|
100
|
+
* (ledger/store.ts entryPath), so the caller's check list decides which
|
|
101
|
+
* prefixes are read: deriving the id back OUT of a slug cannot work, since
|
|
102
|
+
* both `:` and `/` slug to `_`. */
|
|
103
|
+
export function scanCheckStore(store: string, checks: { id: string }[]): StoreSample[] {
|
|
104
|
+
const byPrefix = new Map<string, string>()
|
|
105
|
+
for (const u of checks) byPrefix.set(storeSlug(u.id), u.id)
|
|
106
|
+
const dir = join(store, 'checks')
|
|
107
|
+
const out: StoreSample[] = []
|
|
108
|
+
let files: string[] = []
|
|
109
|
+
try { files = readdirSync(dir) } catch { return out }
|
|
110
|
+
for (const f of files) {
|
|
111
|
+
if (!f.endsWith('.json')) continue
|
|
112
|
+
// `<slug>.<12 hex of the key>.json`
|
|
113
|
+
const key = byPrefix.get(f.replace(/\.[0-9a-f]{12}\.json$/, ''))
|
|
114
|
+
if (key == null) continue
|
|
115
|
+
try {
|
|
116
|
+
const e = JSON.parse(readFileSync(join(dir, f), 'utf8')) as { at?: number; payload?: { measuredMs?: number; status?: RunStatus } }
|
|
117
|
+
const ms = e.payload?.measuredMs
|
|
118
|
+
if (typeof e.at !== 'number' || typeof ms !== 'number') continue
|
|
119
|
+
out.push({ key, at: e.at, ms, status: e.payload?.status === 'warn' ? 'warn' : 'pass' })
|
|
120
|
+
} catch { /* a half written or foreign entry reads as no sample */ }
|
|
121
|
+
}
|
|
122
|
+
return out
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** ledger/store.ts entryPath's file name rule, which this module must agree
|
|
126
|
+
* with byte for byte or it reads nothing */
|
|
127
|
+
const storeSlug = (s: string): string => s.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 60)
|
|
128
|
+
|
|
129
|
+
type Cached = { stamp: string; stats: Map<string, CheckStat> }
|
|
130
|
+
const cache = new Map<string, Cached>()
|
|
131
|
+
|
|
132
|
+
/** the per check numbers for a repo, cached on what the two sources look like
|
|
133
|
+
* from the outside: the store dir's mtime, plus what the run record holds.
|
|
134
|
+
* The store is thousands of small files and the hub asks on a ten second
|
|
135
|
+
* poll. The record's own FILE cannot answer for it: in WAL mode a commit
|
|
136
|
+
* lands in the sidecar and the db's mtime does not move. */
|
|
137
|
+
export function checkStatsFor(root: string, checks: { id: string }[], o?: { fresh?: boolean }): Map<string, CheckStat> {
|
|
138
|
+
const store = storeRoot(root)
|
|
139
|
+
const record = runsPath(root)
|
|
140
|
+
const stamp = `${checks.length}:${stampOf(join(store, 'checks'))}:${runsStamp(record)}`
|
|
141
|
+
const hit = cache.get(root)
|
|
142
|
+
if (o?.fresh !== true && hit != null && hit.stamp === stamp) return hit.stats
|
|
143
|
+
const stats = mergeCheckStats({ runs: readRuns(record), store: scanCheckStore(store, checks) })
|
|
144
|
+
cache.set(root, { stamp, stats })
|
|
145
|
+
return stats
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const stampOf = (p: string): string => {
|
|
149
|
+
try {
|
|
150
|
+
const s = statSync(p)
|
|
151
|
+
return `${s.mtimeMs}/${s.size}`
|
|
152
|
+
} catch {
|
|
153
|
+
return 'none'
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// THE inventory render — every gate check as one markdown row, so the question
|
|
2
|
+
// "what runs here, does it block me, and how often does it really run" has a
|
|
3
|
+
// surface that is read rather than reconstructed from three commands.
|
|
4
|
+
//
|
|
5
|
+
// Deliberately carries NO live run state: no timestamps, no key hashes, no
|
|
6
|
+
// last verdict. Everything here is what a check DECLARED about itself, so two
|
|
7
|
+
// runs a week apart over an unchanged repo write identical bytes and the file
|
|
8
|
+
// never churns. It is still generated, gitignored AND untracked like STATUS.md
|
|
9
|
+
// (rules/untrack-generated.ts), so it is read on disk, never in git history.
|
|
10
|
+
// Live keys are `shipkit keys`, live verdicts are STATUS.md.
|
|
11
|
+
import { mkdirSync } from 'fs'
|
|
12
|
+
import { dirname, join } from 'path'
|
|
13
|
+
import { atomicWrite, withFileLock } from '../core/locked-write.ts'
|
|
14
|
+
import { classify } from './git-key.ts'
|
|
15
|
+
import type { CheckRow } from './gate-checks.ts'
|
|
16
|
+
import { LAYOUT } from '../core/layout.ts'
|
|
17
|
+
|
|
18
|
+
/** the sidecar every gate run refreshes beside STATUS.md */
|
|
19
|
+
export const CHECK_TABLE_FILE = LAYOUT.statusFull
|
|
20
|
+
|
|
21
|
+
/** a cell can never break the table it sits in */
|
|
22
|
+
const cell = (s: string): string => s.replace(/\|/g, '\\|').replace(/\n/g, ' ')
|
|
23
|
+
|
|
24
|
+
export type CheckTableInput = {
|
|
25
|
+
/** repo name, for the headline */
|
|
26
|
+
name: string
|
|
27
|
+
rows: CheckRow[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** what a check's cache declaration means for how often it actually runs */
|
|
31
|
+
function cacheOf(r: CheckRow): { population: string; ttl: string; reads: string; why: string } {
|
|
32
|
+
const c = classify(r.decl)
|
|
33
|
+
const inputs = r.decl.inputs ?? []
|
|
34
|
+
if (c.error != null) return { population: 'BROKEN', ttl: '', reads: '', why: c.error }
|
|
35
|
+
if (c.population === 'content') return { population: 'content', ttl: '', reads: inputs.join(', '), why: '' }
|
|
36
|
+
// what it read is recorded per run, so the table (which never churns)
|
|
37
|
+
// says where to look rather than a list that moves with every edit
|
|
38
|
+
if (r.decl.uncacheable != null) return { population: 'never', ttl: '', reads: '', why: r.decl.uncacheable }
|
|
39
|
+
if (c.population === 'reads') return { population: 'reads', ttl: '', reads: 'recorded per run (shipkit keys --why)', why: '' }
|
|
40
|
+
// no `reads` for these two: the cache column already says "world"/"never",
|
|
41
|
+
// and restating it on every such row is a column of noise
|
|
42
|
+
if (c.population === 'world') return { population: 'world', ttl: r.decl.ttl ?? '', reads: '', why: '' }
|
|
43
|
+
if (c.decided === true) return { population: 'never', ttl: '', reads: '', why: r.decl.never ?? '' }
|
|
44
|
+
return { population: 'UNDECLARED', ttl: '', reads: '', why: 'nobody has declared what this reads, so it re-runs every single time' }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** EVERY CELL of one check's row, in the one vocabulary. Both renders read
|
|
48
|
+
* this, so a column cannot exist on the terminal table and not in the file,
|
|
49
|
+
* or say two different things in the two places. */
|
|
50
|
+
export type CheckCells = {
|
|
51
|
+
check: string
|
|
52
|
+
runsIn: string
|
|
53
|
+
from: string
|
|
54
|
+
build: string
|
|
55
|
+
failure: string
|
|
56
|
+
cache: string
|
|
57
|
+
ttl: string
|
|
58
|
+
reads: string
|
|
59
|
+
owner: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function checkCells(r: CheckRow): CheckCells {
|
|
63
|
+
const c = cacheOf(r)
|
|
64
|
+
return {
|
|
65
|
+
check: r.decl.id,
|
|
66
|
+
runsIn: r.when.at,
|
|
67
|
+
from: r.from,
|
|
68
|
+
// a flag column: the word is in the header, so the cell only has to
|
|
69
|
+
// answer, and an empty cell is quieter than a column of `no`
|
|
70
|
+
build: r.build ? 'yes' : '',
|
|
71
|
+
failure: r.blocking == null ? 'UNDECLARED' : r.blocking ? 'blocks' : 'warns',
|
|
72
|
+
cache: c.population,
|
|
73
|
+
ttl: c.ttl,
|
|
74
|
+
reads: c.reads,
|
|
75
|
+
owner: r.owner ?? '',
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function renderCheckTable(input: CheckTableInput): string {
|
|
80
|
+
const { name, rows } = input
|
|
81
|
+
const builds = rows.filter((r) => r.build).length
|
|
82
|
+
const shipped = rows.filter((r) => r.from === 'shipkit').length
|
|
83
|
+
|
|
84
|
+
const body: string[] = []
|
|
85
|
+
body.push(`<!-- generated by \`shipkit checks\`, refreshed by every gate run, gitignored (SK004) -->`)
|
|
86
|
+
body.push(`# ${name} checks — ${rows.length} total`)
|
|
87
|
+
body.push('')
|
|
88
|
+
body.push(`Everything \`shipkit ci\` and \`shipkit check\` know how to run: ${builds} build step(s) the gate alone runs, ${rows.length - builds} the health pass reports too. ${shipped} ship with the toolkit, ${rows.length - shipped} belong to this repo.`)
|
|
89
|
+
body.push('')
|
|
90
|
+
body.push('- **runs in** — `always` means every `shipkit ci` runs it, so a red one says THIS change is broken. `release` means the quick gate leaves it out and `shipkit ci --full` plus the release path run it. `health` means only the health pass (`shipkit check`) runs it: it reports the world (a box that is off, a cred nobody minted, a page nobody refreshed), it can sit red for months with nothing wrong, and it never stands between you and a commit. `off` means this repo turned it off on purpose, and the reason is in `repo.config.ts when`.')
|
|
91
|
+
body.push('- **build** — `yes` means the gate alone runs it: it BUILDS (a compiler, a test suite, a bundler), so `shipkit check` leaves it out and reports everything else. A floor slot derives it from the package.json script behind it, and a `*.check.ts` declares it with `build: true`.')
|
|
92
|
+
body.push('- **on failure** — `blocks` stops the run. `warns` prints and carries on. `UNDECLARED` is a defect, not a setting: the check never said and the repo set no default, so the runner treats it as fatal by guess.')
|
|
93
|
+
body.push('- **cache** — `content` is cached while the files it reads are untouched. `world` is cached for its TTL, and a RED that ran is cached too, capped at one hour so the run after a fix sees the fix. `never` runs every time and somebody wrote down why. `UNDECLARED` runs every time and nobody has looked yet, which is the burn-down (`shipkit keys --burn-down`). `SHIPKIT_FRESH=1` busts all of it.')
|
|
94
|
+
const proactive = rows.filter((r) => r.when.at !== 'always').length
|
|
95
|
+
const warns = rows.filter((r) => r.blocking === false).length
|
|
96
|
+
const neverSaid = rows.filter((r) => r.blocking == null).length
|
|
97
|
+
const undeclared = rows.filter((r) => cacheOf(r).population === 'UNDECLARED')
|
|
98
|
+
body.push('')
|
|
99
|
+
// the most useful line in the file, so it sits ABOVE the 68 rows rather
|
|
100
|
+
// than under them
|
|
101
|
+
body.push(
|
|
102
|
+
`**${rows.length - proactive} always, ${proactive} not in the quick gate · ${warns} warn instead of blocking${neverSaid > 0 ? `, ${neverSaid} never said` : ''} · ${undeclared.length} never cached with nobody having looked.**`,
|
|
103
|
+
)
|
|
104
|
+
body.push('')
|
|
105
|
+
body.push('| check | runs in | from | build | on failure | cache | ttl | reads | owner |')
|
|
106
|
+
body.push('|---|---|---|---|---|---|---|---|---|')
|
|
107
|
+
for (const r of rows) {
|
|
108
|
+
const c = checkCells(r)
|
|
109
|
+
// the two answers a reader must never skim past are bolded, and only
|
|
110
|
+
// in the file: a terminal has no bold that a pipe survives
|
|
111
|
+
const loud = (v: string): string => (v === 'UNDECLARED' ? '**UNDECLARED**' : v)
|
|
112
|
+
body.push(
|
|
113
|
+
`| \`${cell(c.check)}\` | ${c.runsIn} | ${c.from} | ${c.build} | ${loud(c.failure)} | ${loud(c.cache)} | ${cell(c.ttl)} | ${cell(c.reads)} | ${c.owner === '' ? '' : `\`${cell(c.owner)}\``} |`,
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
body.push('')
|
|
117
|
+
|
|
118
|
+
const decided = rows.filter((r) => cacheOf(r).population === 'never')
|
|
119
|
+
if (undeclared.length > 0) {
|
|
120
|
+
body.push(`## ${undeclared.length} check(s) nothing can cache yet`)
|
|
121
|
+
body.push('')
|
|
122
|
+
body.push('Each of these runs on every single pass. Declaring `inputs` (it reads files), `ttl` (it reads the world) or `never` (nothing can describe it) is the fix, and the last one counts: a written reason is a decision, not a hole.')
|
|
123
|
+
body.push('')
|
|
124
|
+
for (const r of undeclared) body.push(`- \`${cell(r.decl.id)}\`${r.owner == null ? '' : ` — \`${cell(r.owner)}\``}`)
|
|
125
|
+
body.push('')
|
|
126
|
+
}
|
|
127
|
+
if (decided.length > 0) {
|
|
128
|
+
body.push(`## ${decided.length} check(s) never cached on purpose`)
|
|
129
|
+
body.push('')
|
|
130
|
+
for (const r of decided) body.push(`- \`${cell(r.decl.id)}\` — ${cell(r.decl.never ?? '')}`)
|
|
131
|
+
body.push('')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return body.join('\n')
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** the same inventory as a plain terminal table (`shipkit checks`). A catalog,
|
|
138
|
+
* not a health run, so no line carries a wire prefix: anything parsing wire
|
|
139
|
+
* output would read this as a passing check. */
|
|
140
|
+
export function renderCheckLines(rows: CheckRow[]): string[] {
|
|
141
|
+
const out: string[] = [`${'check'.padEnd(28)} ${'runs in'.padEnd(8)} ${'from'.padEnd(8)} ${'build'.padEnd(6)} ${'failure'.padEnd(10)} ${'cache'.padEnd(11)} ttl`]
|
|
142
|
+
for (const r of rows) {
|
|
143
|
+
const c = checkCells(r)
|
|
144
|
+
out.push(`${c.check.padEnd(28)} ${c.runsIn.padEnd(8)} ${c.from.padEnd(8)} ${c.build.padEnd(6)} ${c.failure.padEnd(10)} ${c.cache.padEnd(11)} ${c.ttl}`)
|
|
145
|
+
}
|
|
146
|
+
const undeclared = rows.filter((r) => { const cl = classify(r.decl); return cl.population === 'none' && cl.decided !== true }).length
|
|
147
|
+
const proactive = rows.filter((r) => r.when.at !== 'always').length
|
|
148
|
+
out.push('')
|
|
149
|
+
out.push(`${rows.length} checks — ${rows.length - proactive} always, ${proactive} not in the quick gate · ${undeclared} never cached with nobody having looked`)
|
|
150
|
+
return out
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** THE FIXABLE CATALOG (`shipkit checks --fixable`): everything in this repo
|
|
154
|
+
* that repairs itself, with the page that owns the invariant and where the
|
|
155
|
+
* check runs, so an off one cannot look like a repair waiting to happen. A
|
|
156
|
+
* catalog, not a health run, so no line carries a wire prefix. */
|
|
157
|
+
export function renderFixableLines(rows: CheckRow[]): string[] {
|
|
158
|
+
if (rows.length === 0) return ['no check in this repo carries an autofix']
|
|
159
|
+
const name = (r: CheckRow): string => (r.title === r.decl.id ? r.decl.id : `${r.decl.id} ${r.title}`)
|
|
160
|
+
const w = Math.max(28, ...rows.map((r) => name(r).length))
|
|
161
|
+
const out: string[] = [`${'check'.padEnd(w)} ${'from'.padEnd(8)} ${'runs in'.padEnd(8)} ${'failure'.padEnd(10)} owner`]
|
|
162
|
+
for (const r of rows) {
|
|
163
|
+
const c = checkCells(r)
|
|
164
|
+
out.push(`${name(r).padEnd(w)} ${c.from.padEnd(8)} ${c.runsIn.padEnd(8)} ${c.failure.padEnd(10)} ${c.owner}`)
|
|
165
|
+
}
|
|
166
|
+
out.push('')
|
|
167
|
+
out.push(`${rows.length} check(s) repair themselves — one with \`shipkit check --fix ${rows[0]!.decl.id}\`, every one with \`shipkit check --fix\``)
|
|
168
|
+
return out
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** refresh the sidecar beside STATUS.md. Lock-guarded exactly like STATUS.md:
|
|
172
|
+
* several runners in one checkout is the normal case, and the loser skipping
|
|
173
|
+
* a render it would have written identically costs nothing. */
|
|
174
|
+
export function writeCheckTable(root: string, input: CheckTableInput): { written: boolean; warn?: string } {
|
|
175
|
+
const target = join(root, CHECK_TABLE_FILE)
|
|
176
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
177
|
+
const r = withFileLock(target, () => { atomicWrite(target, `${renderCheckTable(input).trimEnd()}\n`) })
|
|
178
|
+
return r.ok ? { written: true } : { written: false, warn: `${CHECK_TABLE_FILE} locked by another runner — skipped this write` }
|
|
179
|
+
}
|