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
|
@@ -1,30 +1,27 @@
|
|
|
1
|
-
// normative WHAT for .
|
|
1
|
+
// normative WHAT for .shipkit/itch/ — the WHY + workflow live in the sibling page.
|
|
2
2
|
import { custom, defineContract, dirExists, fileContains, glob, md, png, script } from 'rvlib-shipkit/src/contracts/contract-types.ts'
|
|
3
|
-
import { existsSync, readFileSync } from 'fs'
|
|
4
|
-
import { join } from 'path'
|
|
5
3
|
|
|
6
4
|
export default defineContract({
|
|
7
5
|
venue: 'itch',
|
|
8
|
-
dir: '.
|
|
6
|
+
dir: '.shipkit/itch',
|
|
9
7
|
owner: 'distribute/itch.md',
|
|
10
8
|
appliesTo: 'declared',
|
|
11
|
-
detect: [script('^(up|push):itch$'), fileContains('deploy.sh', 'butler push'), dirExists('.
|
|
9
|
+
detect: [script('^(up|push):itch$'), fileContains('deploy.sh', 'butler push'), dirExists('.shipkit/itch')],
|
|
12
10
|
artifacts: [
|
|
13
11
|
// one AI-disclosure wording everywhere — page.md is its canonical home
|
|
14
|
-
md('.
|
|
12
|
+
md('.shipkit/itch/page.md', { mustContain: ['## AI disclosure'] }),
|
|
15
13
|
// every field of itch's create-project form, answered and ready to
|
|
16
14
|
// paste — the listing is a browser-only act, so the artifact IS the
|
|
17
15
|
// handoff (references/listing.template.md is the fill-in copy)
|
|
18
|
-
md('.
|
|
16
|
+
md('.shipkit/itch/listing.md', { mustContain: ['## Title', '## Short description or tagline', '## Classification', '## Genre', '## Tags', '## Visibility'] }),
|
|
19
17
|
// headings alone would pass the TEMPLATE copied in verbatim, and this
|
|
20
18
|
// file exists to be pasted with no decision left in it
|
|
21
|
-
custom('.
|
|
22
|
-
const
|
|
23
|
-
if (
|
|
24
|
-
const text = readFileSync(p, 'utf8')
|
|
19
|
+
custom('.shipkit/itch/listing.md answered', (env) => {
|
|
20
|
+
const text = env.read('.shipkit/itch/listing.md')
|
|
21
|
+
if (text == null) return []
|
|
25
22
|
const out: string[] = []
|
|
26
23
|
const left = [...text.matchAll(/<[a-z][^<>\n]{2,60}>/g)].map((m) => m[0])
|
|
27
|
-
if (left.length > 0) out.push(`.
|
|
24
|
+
if (left.length > 0) out.push(`.shipkit/itch/listing.md still holds ${left.length} unanswered placeholder(s): ${[...new Set(left)].slice(0, 3).join(' ')} — it is pasted into the store form as-is`)
|
|
28
25
|
// a heading with an EMPTY body is an unanswered field wearing no
|
|
29
26
|
// placeholder (prose-level open decisions stay a human read).
|
|
30
27
|
// fenced blocks stripped first (a ## inside one is not a field),
|
|
@@ -37,11 +34,11 @@ export default defineContract({
|
|
|
37
34
|
return !inline && body.every((l) => l.trim() === '')
|
|
38
35
|
})
|
|
39
36
|
.map((s) => s.split('\n')[0]!.trim())
|
|
40
|
-
if (empty.length > 0) out.push(`.
|
|
37
|
+
if (empty.length > 0) out.push(`.shipkit/itch/listing.md field(s) with no answer under the heading: ${empty.slice(0, 3).join(', ')}${empty.length > 3 ? ' …' : ''}`)
|
|
41
38
|
return out
|
|
42
39
|
}),
|
|
43
40
|
// itch DISPLAYS 630x500 and accepts larger — atLeast, never exact-size
|
|
44
|
-
png('.
|
|
45
|
-
glob('.
|
|
41
|
+
png('.shipkit/itch/cover.png', 630, 500, { atLeast: true }),
|
|
42
|
+
glob('.shipkit/itch/screenshots/*.png', { min: 3 }),
|
|
46
43
|
],
|
|
47
44
|
})
|
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
// normative WHAT for .
|
|
1
|
+
// normative WHAT for .shipkit/social/ (posts ONLY — the Discord server contract is
|
|
2
2
|
// discord.contract.ts). Declared = the repo config lists social channels.
|
|
3
3
|
import { custom, defineContract, dirExists } from 'rvlib-shipkit/src/contracts/contract-types.ts'
|
|
4
4
|
|
|
5
5
|
export default defineContract({
|
|
6
6
|
venue: 'social',
|
|
7
|
-
dir: '.
|
|
7
|
+
dir: '.shipkit/social',
|
|
8
8
|
owner: 'distribute/post-publicly.md',
|
|
9
9
|
appliesTo: 'declared',
|
|
10
|
-
detect: [dirExists('.
|
|
10
|
+
detect: [dirExists('.shipkit/social')],
|
|
11
11
|
artifacts: [
|
|
12
12
|
custom('declared-channel-dirs', (env) =>
|
|
13
13
|
env.social
|
|
14
|
-
.filter((ch) => !env.exists(`.
|
|
15
|
-
.map((ch) => `.
|
|
14
|
+
.filter((ch) => !env.exists(`.shipkit/social/${ch}`))
|
|
15
|
+
.map((ch) => `.shipkit/social/${ch}/ missing — declared social channel '${ch}' has no post folder`),
|
|
16
16
|
),
|
|
17
17
|
],
|
|
18
18
|
})
|
|
@@ -1,28 +1,28 @@
|
|
|
1
|
-
// normative WHAT for .
|
|
1
|
+
// normative WHAT for .shipkit/steam/ — the WHY + workflow live in the sibling page.
|
|
2
2
|
// Capsule sizes as of 2026-07 — Valve churns them: re-verify at use, bump here.
|
|
3
3
|
import { defineContract, dir, dirExists, file, fileContains, glob, md, png, script } from 'rvlib-shipkit/src/contracts/contract-types.ts'
|
|
4
4
|
|
|
5
5
|
export default defineContract({
|
|
6
6
|
venue: 'steam',
|
|
7
|
-
dir: '.
|
|
7
|
+
dir: '.shipkit/steam',
|
|
8
8
|
owner: 'distribute/steam.md',
|
|
9
9
|
appliesTo: 'declared',
|
|
10
|
-
detect: [script('^(up|push):steam$'), fileContains('deploy.sh', 'run_app_build'), dirExists('.
|
|
10
|
+
detect: [script('^(up|push):steam$'), fileContains('deploy.sh', 'run_app_build'), dirExists('.shipkit/steam')],
|
|
11
11
|
artifacts: [
|
|
12
|
-
// store.md carries the same AI-disclosure wording as .
|
|
13
|
-
md('.
|
|
14
|
-
png('.
|
|
15
|
-
png('.
|
|
16
|
-
png('.
|
|
17
|
-
png('.
|
|
18
|
-
png('.
|
|
19
|
-
png('.
|
|
20
|
-
png('.
|
|
21
|
-
png('.
|
|
12
|
+
// store.md carries the same AI-disclosure wording as .shipkit/itch/page.md
|
|
13
|
+
md('.shipkit/steam/store.md', { mustContain: ['## AI disclosure'] }),
|
|
14
|
+
png('.shipkit/steam/capsules/main.png', 1232, 706),
|
|
15
|
+
png('.shipkit/steam/capsules/header.png', 920, 430),
|
|
16
|
+
png('.shipkit/steam/capsules/small.png', 462, 174),
|
|
17
|
+
png('.shipkit/steam/capsules/vertical.png', 748, 896),
|
|
18
|
+
png('.shipkit/steam/capsules/library.png', 600, 900),
|
|
19
|
+
png('.shipkit/steam/capsules/library-hero.png', 3840, 1240),
|
|
20
|
+
png('.shipkit/steam/capsules/library-logo.png', 1280, 720),
|
|
21
|
+
png('.shipkit/steam/capsules/community-icon.png', 184, 184),
|
|
22
22
|
// .ico, dims not png-parseable — presence-checked only
|
|
23
|
-
file('.
|
|
24
|
-
glob('.
|
|
23
|
+
file('.shipkit/steam/capsules/client-icon.ico'),
|
|
24
|
+
glob('.shipkit/steam/screenshots/*.png', { min: 5, width: 1920, height: 1080 }),
|
|
25
25
|
// vdf scripts only exist once the appid does — nag, don't block
|
|
26
|
-
dir('.
|
|
26
|
+
dir('.shipkit/steam/vdf', { warnOnly: true }),
|
|
27
27
|
],
|
|
28
28
|
})
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// behind cmd_Contracts; venue deploy scripts run it before any upload.
|
|
3
3
|
import { loadRepoConfig } from '../repo-config.ts'
|
|
4
4
|
import { CheckCtx } from '../core/check-ctx.ts'
|
|
5
|
-
import { contractsFulfillness } from '../
|
|
5
|
+
import { contractsFulfillness } from '../checks/SK002-contracts-fulfillness.check.ts'
|
|
6
6
|
import { activeContracts, applyContractsResult, contractsConfigFrom } from './apply-contracts.ts'
|
|
7
7
|
import { evaluateContracts } from './contracts-engine.ts'
|
|
8
8
|
import { formatLine } from '../core/wire.ts'
|
|
@@ -24,14 +24,15 @@ export async function runContracts(root: string, o: ContractsOpts = {}): Promise
|
|
|
24
24
|
manifests = manifests.filter((m) => m.venue === o.venue)
|
|
25
25
|
if (manifests.length === 0) { printErr(`FAIL: unknown venue '${o.venue}'`); return 1 }
|
|
26
26
|
}
|
|
27
|
-
const
|
|
27
|
+
const disk = CheckCtx.open({ root, config: c })
|
|
28
|
+
const r = await evaluateContracts(manifests, contractsConfigFrom(c, disk), disk)
|
|
28
29
|
if (o.json === true) {
|
|
29
30
|
print(JSON.stringify({ ok: r.exitCode === 0, outcomes: r.outcomes }))
|
|
30
31
|
return r.exitCode
|
|
31
32
|
}
|
|
32
33
|
const color = process.stdout.isTTY && process.env.NO_COLOR == null
|
|
33
34
|
// same SK002-tagged rendering as `shipkit check` and the ci task
|
|
34
|
-
const t = new CheckCtx(
|
|
35
|
+
const t = new CheckCtx()
|
|
35
36
|
applyContractsResult(t, r, contractsFulfillness)
|
|
36
37
|
for (const l of t.lines) print(formatLine(l, color))
|
|
37
38
|
return r.exitCode
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// JOB ATTACHMENT: two processes asking ONE question (same check, same cache
|
|
2
|
+
// key) get ONE measurement. The first takes `<slot>.lock` and measures, every
|
|
3
|
+
// later asker waits on that lock and adopts the answer it left in
|
|
4
|
+
// `<slot>.json`. The lock is the mutate lease (locked-write.ts), so a holder
|
|
5
|
+
// that died is stolen and a wedged one is bounded, with no second mechanism.
|
|
6
|
+
//
|
|
7
|
+
// The one constraint a reader breaks first: an answer is adopted only when it
|
|
8
|
+
// FINISHED after the asker asked, i.e. it was in flight, and never by an asker
|
|
9
|
+
// that stole the lock: it just called that run gone, so it measures itself
|
|
10
|
+
// rather than print a steal from a pid and then an attach to the same pid. The file is never a
|
|
11
|
+
// cache: a red it holds must not replay for a later asker (the ledger replays
|
|
12
|
+
// greens only), and a green reaches later askers through the ledger, after
|
|
13
|
+
// its post-run re-hash.
|
|
14
|
+
import { mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'fs'
|
|
15
|
+
import { dirname } from 'path'
|
|
16
|
+
import { thisInstance } from './instance.ts'
|
|
17
|
+
import { atomicWrite, withMutateLease } from './locked-write.ts'
|
|
18
|
+
|
|
19
|
+
type Shared<T> = { pid: number; nonce?: string; finishedAt: number; value: T }
|
|
20
|
+
|
|
21
|
+
export type ShareOutcome<T> =
|
|
22
|
+
| { kind: 'measured'; value: T }
|
|
23
|
+
| { kind: 'attached'; value: T; pid: number }
|
|
24
|
+
/** the wait was stopped (an interrupt, a bail): nothing ran */
|
|
25
|
+
| { kind: 'aborted' }
|
|
26
|
+
/** the holder outlived the wait bound: this asker measured anyway */
|
|
27
|
+
| { kind: 'overlapped'; value: T; warn: string }
|
|
28
|
+
|
|
29
|
+
export type ShareOpts = {
|
|
30
|
+
/** when this asker asked: only an answer finished after it is adopted */
|
|
31
|
+
askedAt: number
|
|
32
|
+
label?: string
|
|
33
|
+
print?: (line: string) => void
|
|
34
|
+
/** the wait bound. Past the holder's own ceiling, so a live holder always
|
|
35
|
+
* finishes first. */
|
|
36
|
+
maxWaitMs?: number
|
|
37
|
+
pollMs?: number
|
|
38
|
+
onWait?: (pid: number) => void
|
|
39
|
+
abort?: () => boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** how long an answer file stays on disk. Only an in flight asker can adopt
|
|
43
|
+
* one, so this is housekeeping, not a validity window. */
|
|
44
|
+
const KEEP_MS = 60 * 60 * 1000
|
|
45
|
+
|
|
46
|
+
const readShared = <T>(path: string): Shared<T> | null => {
|
|
47
|
+
try {
|
|
48
|
+
const s = JSON.parse(readFileSync(path, 'utf8')) as Partial<Shared<T>>
|
|
49
|
+
return typeof s.pid === 'number' && typeof s.finishedAt === 'number' && 'value' in s ? (s as Shared<T>) : null
|
|
50
|
+
} catch { return null }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const sweep = (dir: string, now: number): void => {
|
|
54
|
+
try {
|
|
55
|
+
for (const f of readdirSync(dir)) {
|
|
56
|
+
if (f.endsWith('.lock')) continue
|
|
57
|
+
const p = `${dir}/${f}`
|
|
58
|
+
try { if (statSync(p).mtimeMs < now - KEEP_MS) rmSync(p, { force: true }) } catch { /* raced: gone either way */ }
|
|
59
|
+
}
|
|
60
|
+
} catch { /* housekeeping never fails a run */ }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** run `body` once per in flight key. `share: false` from the body (it was
|
|
64
|
+
* killed, it never reached its subject) leaves no answer, so a waiter
|
|
65
|
+
* measures for itself rather than adopting a non answer. */
|
|
66
|
+
export async function shareRun<T>(slot: string, o: ShareOpts, body: () => Promise<{ value: T; share: boolean }>): Promise<ShareOutcome<T>> {
|
|
67
|
+
const answer = `${slot}.json`
|
|
68
|
+
mkdirSync(dirname(slot), { recursive: true })
|
|
69
|
+
let stole = false
|
|
70
|
+
const leased = await withMutateLease(slot, {
|
|
71
|
+
label: o.label ?? 'shared run',
|
|
72
|
+
print: o.print,
|
|
73
|
+
maxWaitMs: o.maxWaitMs,
|
|
74
|
+
pollMs: o.pollMs,
|
|
75
|
+
announceMs: Number.POSITIVE_INFINITY,
|
|
76
|
+
onWait: (h) => o.onWait?.(h.pid),
|
|
77
|
+
onSteal: () => { stole = true },
|
|
78
|
+
abort: o.abort,
|
|
79
|
+
}, async (): Promise<ShareOutcome<T>> => {
|
|
80
|
+
const prior = readShared<T>(answer)
|
|
81
|
+
if (!stole && prior != null && prior.finishedAt >= o.askedAt) return { kind: 'attached', value: prior.value, pid: prior.pid }
|
|
82
|
+
const r = await body()
|
|
83
|
+
const now = Date.now()
|
|
84
|
+
if (r.share) {
|
|
85
|
+
try { atomicWrite(answer, JSON.stringify({ ...thisInstance(), finishedAt: now, value: r.value } satisfies Shared<T>)) } catch {
|
|
86
|
+
// an answer nobody can adopt costs the waiters a measurement, never a verdict
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
sweep(dirname(slot), now)
|
|
90
|
+
return { kind: 'measured', value: r.value }
|
|
91
|
+
})
|
|
92
|
+
if (leased.ok) return leased.value
|
|
93
|
+
if (leased.aborted === true) return { kind: 'aborted' }
|
|
94
|
+
const r = await body()
|
|
95
|
+
return { kind: 'overlapped', value: r.value, warn: leased.warn }
|
|
96
|
+
}
|
|
@@ -74,3 +74,28 @@ export function inferBin(scripts: Record<string, string>): string | undefined {
|
|
|
74
74
|
}
|
|
75
75
|
return undefined
|
|
76
76
|
}
|
|
77
|
+
|
|
78
|
+
/** what fills one floor slot, as far as package.json and `defaults` say: the
|
|
79
|
+
* decision core/floor.ts turns into a task, and rule SK018 reads to know what
|
|
80
|
+
* the root's tests run. `none` leaves the slot to a built-in, if it has one */
|
|
81
|
+
export type SlotBody =
|
|
82
|
+
| { kind: 'off' }
|
|
83
|
+
| { kind: 'override' }
|
|
84
|
+
| { kind: 'script'; name: string; body: string }
|
|
85
|
+
| { kind: 'routed-empty' }
|
|
86
|
+
| { kind: 'none' }
|
|
87
|
+
|
|
88
|
+
export function slotBody(id: string, override: { cmd?: unknown; logic?: unknown } | false | undefined, scripts: Record<string, string>): SlotBody {
|
|
89
|
+
if (override === false) return { kind: 'off' }
|
|
90
|
+
// a FULL override brings its own body and replaces the slot. One that
|
|
91
|
+
// brings none DECORATES whatever fills it
|
|
92
|
+
if (override != null && (override.cmd != null || override.logic != null)) return { kind: 'override' }
|
|
93
|
+
// ONE WAY IN: `<id>` routes to `shipkit ci --only <id>`, so the TOOL body
|
|
94
|
+
// lives at `<id>:raw`. Running the routed script would spawn the gate from
|
|
95
|
+
// inside itself, forever
|
|
96
|
+
const raw = scripts[rawName(id)]
|
|
97
|
+
if (raw != null) return { kind: 'script', name: rawName(id), body: raw }
|
|
98
|
+
const own = scripts[id]
|
|
99
|
+
if (own != null && !routesToShipkit(own)) return { kind: 'script', name: id, body: own }
|
|
100
|
+
return own != null ? { kind: 'routed-empty' } : { kind: 'none' }
|
|
101
|
+
}
|
package/src/core/check-ctx.ts
CHANGED
|
@@ -1,13 +1,43 @@
|
|
|
1
|
-
// CheckCtx — the
|
|
2
|
-
//
|
|
1
|
+
// CheckCtx — the ONE parameter a check body receives, and its only door to the
|
|
2
|
+
// repo. Four parts:
|
|
3
|
+
//
|
|
4
|
+
// report c.ok / note / warn / fail / unreachable, the wire lines (wire.ts)
|
|
5
|
+
// where c.root, c.config, c.frozen
|
|
6
|
+
// reads c.read / json / exists / isLink / list / entries, each recorded with what it saw, and
|
|
7
|
+
// c.use(scan) for a scan every check of the pass shares (scan.ts)
|
|
8
|
+
// world c.exec / fetch / tool, which no read set can describe
|
|
9
|
+
// writes c.write / remove, for a `fix`
|
|
10
|
+
//
|
|
11
|
+
// What a body read is therefore KNOWN rather than declared: a `run` check that
|
|
12
|
+
// declares no cache population is cached on its recorded reads
|
|
13
|
+
// (ledger/ledger.ts), and one that reaches the world through exec, fetch or
|
|
14
|
+
// tool marks its answer untracked, so it is never stored on a read set that
|
|
15
|
+
// cannot describe it. A body whose code reaches the disk around `c` is never
|
|
16
|
+
// keyed on its reads at all (core/raw-access.ts, checked at discovery).
|
|
17
|
+
//
|
|
18
|
+
// A context with no pass reports and reaches the world, and a repo read there
|
|
19
|
+
// throws rather than guess a root.
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, rmSync } from 'fs'
|
|
21
|
+
import { dirname, join } from 'path'
|
|
22
|
+
import { atomicWrite } from './locked-write.ts'
|
|
3
23
|
import { spawnWithTimeout } from './spawn-timeout.ts'
|
|
24
|
+
import { blobOid, dirEntries, entriesHash, isLinkAt, kindOf, listingHash, Reads, type DirEntry } from './read-set.ts'
|
|
25
|
+
import { CheckPass } from './check-pass.ts'
|
|
26
|
+
import { normalizeInput, WHOLE_TREE } from '../ledger/git-scan.ts'
|
|
27
|
+
import type { RepoConfig } from '../repo-config.ts'
|
|
28
|
+
import type { Scan } from '../scan.ts'
|
|
4
29
|
import type { Line } from './wire.ts'
|
|
5
30
|
|
|
6
|
-
// thrown by t.fetch on 429
|
|
7
|
-
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
31
|
+
// thrown by t.fetch on 429: no answer, so the body runner (check.ts
|
|
32
|
+
// runCheckBody) reports it as unreachable, never as a FAIL. It knows it by
|
|
33
|
+
// name, so the narrow check module never loads this one
|
|
34
|
+
export class RateLimited extends Error {
|
|
35
|
+
override name = 'RateLimited'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** what a shared scan may do: know where it is and read. It reports nothing
|
|
39
|
+
* and reaches no world, which is what lets its reads stand for its value. */
|
|
40
|
+
export type ScanCtx = Pick<CheckCtx, 'root' | 'config' | 'frozen' | 'read' | 'bytes' | 'json' | 'exists' | 'isLink' | 'list' | 'entries' | 'use'>
|
|
11
41
|
|
|
12
42
|
export class CheckCtx {
|
|
13
43
|
lines: Line[] = []
|
|
@@ -16,12 +46,28 @@ export class CheckCtx {
|
|
|
16
46
|
/** THIS RUN PRODUCED NO ANSWER: the probe could not run at all (network
|
|
17
47
|
* down, no cred, rate limited, tool missing). Distinct from a probe that
|
|
18
48
|
* ran and came back bad, which IS an answer. The cache replays answers
|
|
19
|
-
* and never replays their absence (
|
|
49
|
+
* and never replays their absence (ledger/world-cache.ts, the measured-negative
|
|
20
50
|
* rule). Set it whenever the reason a check is not green is that it never
|
|
21
51
|
* reached the thing it was asking about. */
|
|
22
52
|
unmeasured = false
|
|
23
|
-
|
|
53
|
+
/** every repo read this context made, with what it saw */
|
|
54
|
+
readonly reads = new Reads()
|
|
55
|
+
/** why the reads cannot describe this answer (it reached the world), null
|
|
56
|
+
* while they can */
|
|
57
|
+
untracked: string | null = null
|
|
24
58
|
|
|
59
|
+
constructor(
|
|
60
|
+
/** the runner pass this body runs in. Absent for a context that only
|
|
61
|
+
* reports and reaches the world */
|
|
62
|
+
readonly pass: CheckPass | null = null,
|
|
63
|
+
) {}
|
|
64
|
+
|
|
65
|
+
/** a context over one repo, outside any runner: a fixer, a test, a tool */
|
|
66
|
+
static open(where: { root: string; config: RepoConfig }): CheckCtx {
|
|
67
|
+
return new CheckCtx(new CheckPass(where.root, where.config))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── report ────────────────────────────────────────────────────────────
|
|
25
71
|
ok(msg: string): void { this.lines.push({ kind: 'ok', text: msg }) }
|
|
26
72
|
note(msg: string): void { this.lines.push({ kind: 'note', text: msg }) }
|
|
27
73
|
/** a WARN that came back from something we could not reach — no answer, so
|
|
@@ -41,15 +87,155 @@ export class CheckCtx {
|
|
|
41
87
|
if (fix != null) this.lines.push({ kind: 'fix-fail', text: fix })
|
|
42
88
|
}
|
|
43
89
|
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
90
|
+
// ── where ─────────────────────────────────────────────────────────────
|
|
91
|
+
private repo(what: string): CheckPass {
|
|
92
|
+
if (this.pass == null) throw new Error(`c.${what} needs a repo, and this context has no pass: it only reports and reaches the world`)
|
|
93
|
+
return this.pass
|
|
94
|
+
}
|
|
95
|
+
get root(): string { return this.repo('root').root }
|
|
96
|
+
/** the repo's config. Reading it records repo.config.ts and every repo
|
|
97
|
+
* module it imports, so a config edit re-asks every check that consulted
|
|
98
|
+
* it and none that did not */
|
|
99
|
+
get config(): RepoConfig {
|
|
100
|
+
const p = this.repo('config')
|
|
101
|
+
const deps = p.configReads()
|
|
102
|
+
for (const [f, oid] of deps.files) this.reads.file(f, oid)
|
|
103
|
+
if (deps.untracked != null && this.untracked == null) {
|
|
104
|
+
this.untrack(deps.untracked)
|
|
105
|
+
this.note(`not cached: ${deps.untracked}`)
|
|
106
|
+
}
|
|
107
|
+
return p.config
|
|
108
|
+
}
|
|
109
|
+
/** a report only run: a check that rewrites the repo must not */
|
|
110
|
+
get frozen(): boolean { return this.repo('frozen').frozen }
|
|
111
|
+
/** the fix pointer a failing body with none of its own gets: an owner
|
|
112
|
+
* that is a file of this repo (a repo check's own path) as it stands, a
|
|
113
|
+
* page of the docs wiki after `docsBase`. The runner's line, never the
|
|
114
|
+
* body's, so nothing here is recorded as a read */
|
|
115
|
+
ownerPointer(owner: string): string {
|
|
116
|
+
const inRepo = this.pass != null && owner !== '' && existsSync(join(this.pass.root, owner))
|
|
117
|
+
return inRepo ? owner : `${this.pass?.config.docsBase ?? ''}${owner}`
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── reads, recorded ───────────────────────────────────────────────────
|
|
121
|
+
private rel(path: string): string {
|
|
122
|
+
const p = normalizeInput(path)
|
|
123
|
+
if (p === WHOLE_TREE) throw new Error(`'${path}' is the repo root, not a file`)
|
|
48
124
|
return p
|
|
49
125
|
}
|
|
126
|
+
/** the text of a repo-relative file, null when there is none */
|
|
127
|
+
read(path: string): string | null {
|
|
128
|
+
return this.bytes(path)?.toString('utf8') ?? null
|
|
129
|
+
}
|
|
130
|
+
/** the raw bytes of a repo-relative file (an image, a binary), null when
|
|
131
|
+
* there is none. Recorded exactly like `read` */
|
|
132
|
+
bytes(path: string): Buffer | null {
|
|
133
|
+
const p = this.rel(path)
|
|
134
|
+
let bytes: Buffer
|
|
135
|
+
try {
|
|
136
|
+
bytes = readFileSync(join(this.repo('read').root, p))
|
|
137
|
+
} catch (e) {
|
|
138
|
+
const code = (e as { code?: string }).code
|
|
139
|
+
if (code !== 'ENOENT' && code !== 'ENOTDIR' && code !== 'EISDIR') throw e
|
|
140
|
+
this.reads.file(p, null)
|
|
141
|
+
return null
|
|
142
|
+
}
|
|
143
|
+
this.reads.file(p, blobOid(bytes))
|
|
144
|
+
return bytes
|
|
145
|
+
}
|
|
146
|
+
/** a repo-relative json file, parsed. null when absent, and a parse error
|
|
147
|
+
* throws naming the file */
|
|
148
|
+
json<T = unknown>(path: string): T | null {
|
|
149
|
+
const text = this.read(path)
|
|
150
|
+
if (text == null) return null
|
|
151
|
+
try {
|
|
152
|
+
return JSON.parse(text) as T
|
|
153
|
+
} catch (e) {
|
|
154
|
+
throw new Error(`${path} is not valid json (${String(e).slice(0, 120)})`)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/** whether something stands at this repo-relative path, or only a file,
|
|
158
|
+
* or only a dir, when `kind` says which */
|
|
159
|
+
exists(path: string, kind?: 'file' | 'dir'): boolean {
|
|
160
|
+
const p = this.rel(path)
|
|
161
|
+
const found = kindOf(join(this.repo('exists').root, p))
|
|
162
|
+
this.reads.exist(p, found)
|
|
163
|
+
return kind == null ? found != null : found === kind
|
|
164
|
+
}
|
|
165
|
+
/** whether a symlink stands at this repo-relative path itself, which
|
|
166
|
+
* `exists` cannot say: it follows the link to what it points at */
|
|
167
|
+
isLink(path: string): boolean {
|
|
168
|
+
const p = this.rel(path)
|
|
169
|
+
const is = isLinkAt(join(this.repo('isLink').root, p))
|
|
170
|
+
this.reads.link(p, is)
|
|
171
|
+
return is
|
|
172
|
+
}
|
|
173
|
+
/** every file matching `glob` that git sees (tracked, or untracked and not
|
|
174
|
+
* ignored), repo-relative and sorted */
|
|
175
|
+
list(glob: string): string[] {
|
|
176
|
+
const view = this.repo('list').view()
|
|
177
|
+
const files = view.list(glob)
|
|
178
|
+
if (view.fallback != null) this.untrack(`listed without a git scan: ${view.fallback}`)
|
|
179
|
+
this.reads.list(glob, listingHash(files))
|
|
180
|
+
return files
|
|
181
|
+
}
|
|
182
|
+
/** the entries directly inside a repo-relative dir, read from the DISK,
|
|
183
|
+
* sorted by name: ignored files included, which `list` leaves out (a
|
|
184
|
+
* gitignored contract folder is still a folder a check must see). Empty
|
|
185
|
+
* when no dir stands there. The listing is recorded, so an entry added,
|
|
186
|
+
* removed or turned from a file into a dir runs the check again */
|
|
187
|
+
entries(dir: string): DirEntry[] {
|
|
188
|
+
const d = normalizeInput(dir)
|
|
189
|
+
const found = dirEntries(d === WHOLE_TREE ? this.repo('entries').root : join(this.repo('entries').root, d))
|
|
190
|
+
this.reads.dir(d, entriesHash(found))
|
|
191
|
+
return found ?? []
|
|
192
|
+
}
|
|
193
|
+
/** a scan's value, computed once per pass for every check that asks, with
|
|
194
|
+
* its reads recorded into this check as if it had made them */
|
|
195
|
+
async use<T>(scan: Scan<T>): Promise<T> {
|
|
196
|
+
const pass = this.repo('use')
|
|
197
|
+
const s = pass.scan(scan, () => new CheckCtx(pass))
|
|
198
|
+
const value = await s.value
|
|
199
|
+
this.reads.merge(s.ctx.reads)
|
|
200
|
+
if (s.ctx.untracked != null) this.untrack(s.ctx.untracked)
|
|
201
|
+
return value
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── writes, for a fixer ───────────────────────────────────────────────
|
|
205
|
+
/** write a repo-relative file whole, its dirs created, never torn. For a
|
|
206
|
+
* `fix`: a body that writes is not a question, and nothing records it */
|
|
207
|
+
write(path: string, text: string): void {
|
|
208
|
+
const abs = join(this.repo('write').root, this.rel(path))
|
|
209
|
+
mkdirSync(dirname(abs), { recursive: true })
|
|
210
|
+
atomicWrite(abs, text)
|
|
211
|
+
}
|
|
212
|
+
/** delete a repo-relative file or dir, and nothing when it is absent */
|
|
213
|
+
remove(path: string): void {
|
|
214
|
+
rmSync(join(this.repo('remove').root, this.rel(path)), { recursive: true, force: true })
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── world ─────────────────────────────────────────────────────────────
|
|
218
|
+
/** this answer depends on something no read here recorded (a path
|
|
219
|
+
* outside the repo, git history), so it must never be stored on its
|
|
220
|
+
* reads. exec, fetch and tool say it for themselves */
|
|
221
|
+
untrack(reason: string): void {
|
|
222
|
+
this.untracked ??= reason
|
|
223
|
+
}
|
|
224
|
+
private world(what: string): void {
|
|
225
|
+
this.untrack(`it reached the world through ${what}`)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** where a binary lives on this machine, null when it is not installed.
|
|
229
|
+
* An explicit candidate path that exists wins over PATH, so a stale
|
|
230
|
+
* global never shadows the one a repo pins */
|
|
231
|
+
tool(name: string, candidates: string[] = []): string | null {
|
|
232
|
+
this.world('tool')
|
|
233
|
+
return candidates.find((p) => existsSync(p)) ?? Bun.which(name)
|
|
234
|
+
}
|
|
50
235
|
|
|
51
236
|
// fetch with a default timeout; 429 → RateLimited → WARN upstream
|
|
52
237
|
async fetch(url: string, init?: RequestInit & { timeoutMs?: number }): Promise<Response> {
|
|
238
|
+
this.world('fetch')
|
|
53
239
|
const res = await fetch(url, { ...init, signal: init?.signal ?? AbortSignal.timeout(init?.timeoutMs ?? 10_000) })
|
|
54
240
|
if (res.status === 429) throw new RateLimited(`${new URL(url).host} → 429 rate-limited`)
|
|
55
241
|
return res
|
|
@@ -58,6 +244,10 @@ export class CheckCtx {
|
|
|
58
244
|
// spawn without the Bun.spawn throw-on-missing-binary gotcha; SIGTERM at
|
|
59
245
|
// timeoutMs, SIGKILL 2s later (one code path: core/spawn-timeout.ts)
|
|
60
246
|
async exec(cmd: string[], opts?: { env?: Record<string, string | undefined>; cwd?: string; timeoutMs?: number }): Promise<{ code: number; out: string; timedOut: boolean }> {
|
|
61
|
-
|
|
247
|
+
this.world('exec')
|
|
248
|
+
// the environment as it stands NOW: left out, Bun hands the child the
|
|
249
|
+
// one it started with, and a variable set since (a test seam, a flag
|
|
250
|
+
// the gate set) never reaches it
|
|
251
|
+
return spawnWithTimeout(cmd, { timeoutMs: opts?.timeoutMs ?? 15_000, env: opts?.env ?? { ...process.env }, cwd: opts?.cwd })
|
|
62
252
|
}
|
|
63
253
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// WHICH files are check files, and what they are called, from paths and
|
|
2
|
+
// source text alone. No disk access here, so a check that must know the
|
|
3
|
+
// repo's check files (SK016) reads them through its context and stays cached
|
|
4
|
+
// on what it read. core/discover-checks.ts walks the disk with the same rules.
|
|
5
|
+
import { basename, relative } from 'path'
|
|
6
|
+
|
|
7
|
+
export const CHECK_SUFFIX = '.check.ts'
|
|
8
|
+
|
|
9
|
+
/** dirs discovery never walks into, beside every dot dir: installs, build
|
|
10
|
+
* output, scratch, and templates meant for other repos */
|
|
11
|
+
export const SKIPPED_DIRS: ReadonlySet<string> = new Set(['node_modules', 'dist', 'tmp', 'references'])
|
|
12
|
+
|
|
13
|
+
/** is this repo-relative path a file discovery would find: the suffix, and no
|
|
14
|
+
* dir on the way that the walk skips */
|
|
15
|
+
export function isCheckFilePath(rel: string): boolean {
|
|
16
|
+
if (!rel.endsWith(CHECK_SUFFIX)) return false
|
|
17
|
+
const dirs = rel.split('/').slice(0, -1)
|
|
18
|
+
return dirs.every((d) => !SKIPPED_DIRS.has(d) && !d.startsWith('.'))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// a declaration only the legacy script shape writes, at the top level. A
|
|
22
|
+
// comment or an indented line never matches, so a check file that MENTIONS
|
|
23
|
+
// the old shape stays a check file
|
|
24
|
+
const LEGACY_DECL_RE = /^[ \t]*export\s+const\s+(blocking|inputs|ttl|never|gate)\b/m
|
|
25
|
+
// a default export that can be the check object: a call, a literal or a
|
|
26
|
+
// name. A function or a class is a script's own business, never a check
|
|
27
|
+
const DEFAULT_OBJECT_RE = /^[ \t]*export\s+default\s+(?!(async\s+)?function\b|class\b)\S/m
|
|
28
|
+
|
|
29
|
+
/** is this `*.check.ts` source a legacy check SCRIPT rather than a check
|
|
30
|
+
* file? Read, never imported: a script runs its whole body on import. It is
|
|
31
|
+
* legacy when it carries a legacy declaration, or when nothing it exports by
|
|
32
|
+
* default could be a check object. */
|
|
33
|
+
export function isLegacyCheckSource(src: string): boolean {
|
|
34
|
+
return LEGACY_DECL_RE.test(src) || !DEFAULT_OBJECT_RE.test(src)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** the name a repo file answers to when it declares no id: its basename, or
|
|
38
|
+
* its repo-relative path when another `*.check.ts` shares that basename.
|
|
39
|
+
* Counted over EVERY file with the suffix, legacy scripts included, so both
|
|
40
|
+
* readers give one file one name. */
|
|
41
|
+
export function fallbackNames(root: string, files: string[]): Map<string, string> {
|
|
42
|
+
const base = (f: string): string => basename(f).slice(0, -CHECK_SUFFIX.length)
|
|
43
|
+
const seen = new Map<string, number>()
|
|
44
|
+
for (const f of files) seen.set(base(f), (seen.get(base(f)) ?? 0) + 1)
|
|
45
|
+
return new Map(files.map((f) => [f, (seen.get(base(f)) ?? 0) > 1 ? relative(root, f).replace(/\\/g, '/').slice(0, -CHECK_SUFFIX.length) : base(f)]))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type LegacyCheck = {
|
|
49
|
+
/** absolute path to the script */
|
|
50
|
+
path: string
|
|
51
|
+
/** display + cache name — file base, disambiguated by relative path on collision */
|
|
52
|
+
name: string
|
|
53
|
+
/** `export const blocking = true | false`, read statically. null = the file
|
|
54
|
+
* never said, which is an error: a check that silently does not block is
|
|
55
|
+
* the failure nobody sees. */
|
|
56
|
+
blocking: boolean | null
|
|
57
|
+
/** `export const inputs = ['a', 'b']`, read statically — the repo-relative
|
|
58
|
+
* paths this check reads. Declares the CONTENT population for the ledger
|
|
59
|
+
* key layer (ledger/git-key.ts). null = never cached, which is the safe
|
|
60
|
+
* default and the whole point of default deny. */
|
|
61
|
+
inputs: string[] | null
|
|
62
|
+
/** `export const ttl = '24h'`, read statically — declares the WORLD
|
|
63
|
+
* population: this check probes something no file on disk describes. */
|
|
64
|
+
ttl: string | null
|
|
65
|
+
/** `export const never = 'why'`, read statically — a DECIDED never
|
|
66
|
+
* cacheable, which takes the check off the burn-down list. */
|
|
67
|
+
never: string | null
|
|
68
|
+
/** `export const gate = false`, read statically — this check is NOT part of
|
|
69
|
+
* `shipkit ci`. `ci` answers "is my change good"; a check that answers
|
|
70
|
+
* something else, and that can sit red for months without anything being
|
|
71
|
+
* wrong, does not belong in the answer. `shipkit check` still runs it.
|
|
72
|
+
* Default true: a check is in the gate unless it opts out by name. */
|
|
73
|
+
gate: boolean
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// read without importing: checks run as subprocesses, and discovery must stay
|
|
77
|
+
// pure-fs. `export const blocking = true` (or false), nothing cleverer.
|
|
78
|
+
// the tail is the gate's OWN formatter: oxfmt terminates every statement, and
|
|
79
|
+
// the fix pass runs it, so a reader that refuses a semicolon refuses the shape
|
|
80
|
+
// the gate itself just wrote.
|
|
81
|
+
const TAIL = String.raw`(?:\s+as\s+const)?\s*;?\s*$`
|
|
82
|
+
const BLOCKING_RE = new RegExp(String.raw`^\s*export\s+const\s+blocking\s*(?::\s*boolean\s*)?=\s*(true|false)` + TAIL, 'm')
|
|
83
|
+
// `export const inputs = ['a', 'b']`, single or multi line. Anything cleverer
|
|
84
|
+
// (a computed list, a spread) is deliberately unreadable here and lands as no
|
|
85
|
+
// declaration, which means never cached: a scanner guessing at a program is
|
|
86
|
+
// how a key comes to describe files the check does not read.
|
|
87
|
+
const INPUTS_RE = /^\s*export\s+const\s+inputs\s*(?::[^=]*)?=\s*\[([^\]]*)\]/m
|
|
88
|
+
const TTL_RE = /^\s*export\s+const\s+ttl\s*(?::\s*string\s*)?=\s*['"]([^'"]+)['"]/m
|
|
89
|
+
const NEVER_RE = /^\s*export\s+const\s+never\s*(?::\s*string\s*)?=\s*['"]([^'"]+)['"]/m
|
|
90
|
+
// `export const gate = false` takes a check out of `shipkit ci`. Only the
|
|
91
|
+
// literal false opts out: anything unreadable stays in the gate, because a
|
|
92
|
+
// scanner that guesses its way to SKIPPING a check is how coverage disappears
|
|
93
|
+
const GATE_RE = new RegExp(String.raw`^\s*export\s+const\s+gate\s*(?::\s*boolean\s*)?=\s*false` + TAIL, 'm')
|
|
94
|
+
const STRING_ITEM_RE = /['"]([^'"]+)['"]/g
|
|
95
|
+
|
|
96
|
+
/** the declarations of one legacy check script, read out of its source. Null
|
|
97
|
+
* when the source is a check file instead */
|
|
98
|
+
export function readLegacyCheck(path: string, name: string, src: string): LegacyCheck | null {
|
|
99
|
+
if (!isLegacyCheckSource(src)) return null
|
|
100
|
+
const m = src.match(BLOCKING_RE)
|
|
101
|
+
const im = src.match(INPUTS_RE)
|
|
102
|
+
const items = im == null ? [] : [...im[1]!.matchAll(STRING_ITEM_RE)].map((x) => x[1]!)
|
|
103
|
+
return {
|
|
104
|
+
path,
|
|
105
|
+
name,
|
|
106
|
+
blocking: m == null ? null : m[1] === 'true',
|
|
107
|
+
inputs: items.length > 0 ? items : null,
|
|
108
|
+
ttl: src.match(TTL_RE)?.[1] ?? null,
|
|
109
|
+
never: src.match(NEVER_RE)?.[1] ?? null,
|
|
110
|
+
gate: src.match(GATE_RE) == null,
|
|
111
|
+
}
|
|
112
|
+
}
|