rvlib-shipkit 0.38.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +371 -16
- package/README.md +26 -14
- package/package.json +2 -2
- package/src/assets/gen/comfyts-gen.ts +27 -10
- package/src/assets/gen/generate-roll.ts +1 -1
- package/src/assets/kit-config.ts +6 -6
- package/src/assets/run-assets.ts +14 -10
- package/src/assets/server/studio-server.ts +6 -5
- package/src/check.ts +343 -0
- package/src/{rules/SK001-package-json-shape.ts → checks/SK001-package-json-shape.check.ts} +44 -46
- package/src/checks/SK002-contracts-fulfillness.check.ts +24 -0
- package/src/checks/SK003-claude-md-shape.check.ts +66 -0
- package/src/checks/SK004-gitignore-policy.check.ts +309 -0
- package/src/{rules/SK005-rvlib-versions.ts → checks/SK005-rvlib-versions.check.ts} +46 -32
- package/src/{rules/SK006-npm-publish-safety.ts → checks/SK006-npm-publish-safety.check.ts} +25 -24
- package/src/{rules/SK007-github-visibility.ts → checks/SK007-github-visibility.check.ts} +20 -14
- package/src/{rules/SK008-reviewer-agent.ts → checks/SK008-reviewer-agent.check.ts} +29 -14
- package/src/{rules/SK009-commit-ai-tells.ts → checks/SK009-commit-ai-tells.check.ts} +24 -21
- package/src/checks/SK010-undeclared-unit.check.ts +111 -0
- package/src/{rules/SK011-sibling-unit-ranges.ts → checks/SK011-sibling-unit-ranges.check.ts} +21 -20
- package/src/{rules/SK012-config-barrel-import.ts → checks/SK012-config-barrel-import.check.ts} +18 -23
- package/src/{rules/SK013-one-way-in-policy.ts → checks/SK013-one-way-in-policy.check.ts} +58 -50
- package/src/{rules/SK014-prose-ste.ts → checks/SK014-prose-ste.check.ts} +27 -13
- package/src/{rules/SK015-nested-repo.ts → checks/SK015-nested-repo.check.ts} +15 -12
- package/src/checks/SK016-legacy-check-shapes.check.ts +182 -0
- package/src/checks/SK017-vscode-hub-task.check.ts +197 -0
- package/src/checks/SK018-test-files-run.check.ts +28 -0
- package/src/checks/SK019-tests-run-in-units.check.ts +76 -0
- package/src/checks/SK020-self-spawning-check.check.ts +147 -0
- package/src/checks/contracts.check.ts +20 -0
- package/src/cli/cmd_Asks.ts +3 -2
- package/src/cli/cmd_Assets.ts +2 -2
- package/src/cli/cmd_Bump.ts +1 -1
- package/src/cli/cmd_Check.ts +6 -6
- package/src/cli/cmd_Checks.ts +95 -0
- package/src/cli/cmd_Ci.ts +48 -13
- package/src/cli/cmd_Commands.ts +2 -6
- package/src/cli/cmd_ConfigKeys.ts +69 -0
- package/src/cli/cmd_Contracts.ts +1 -1
- package/src/cli/cmd_Fleet.ts +6 -2
- package/src/cli/cmd_Hooks.ts +1 -1
- package/src/cli/cmd_Keys.ts +43 -18
- package/src/cli/cmd_Mv.ts +2 -2
- package/src/cli/cmd_Prose.ts +10 -1
- package/src/cli/cmd_Releases.ts +2 -2
- package/src/cli/cmd_Serve.ts +1 -1
- package/src/cli/cmd_Start.ts +6 -6
- package/src/cli/cmd_Status.ts +25 -0
- package/src/cli/cmd_Tui.ts +2 -2
- package/src/cli.ts +7 -4
- package/src/collections/actions.ts +4 -4
- package/src/collections/checks.ts +258 -0
- package/src/collections/collection-types.ts +254 -1
- package/src/collections/comfy.ts +23 -0
- package/src/collections/md-body.ts +14 -0
- package/src/collections/pings.ts +38 -12
- package/src/collections/posts.ts +19 -3
- package/src/collections/providers.ts +198 -91
- package/src/collections/reflections.ts +41 -0
- package/src/collections/state-rules.ts +73 -0
- package/src/collections/test-time.ts +288 -0
- package/src/collections/tests.ts +181 -0
- package/src/collections/wikis.ts +66 -0
- package/src/comfy/comfy-panel.ts +292 -0
- package/src/contracts/apply-contracts.ts +17 -12
- package/src/contracts/contract-types.ts +16 -4
- package/src/contracts/contracts-engine.ts +78 -42
- package/src/contracts/manifests/discord.contract.ts +6 -6
- package/src/contracts/manifests/identity.contract.ts +3 -3
- package/src/contracts/manifests/itch.contract.ts +12 -15
- package/src/contracts/manifests/social.contract.ts +5 -5
- package/src/contracts/manifests/steam.contract.ts +16 -16
- package/src/contracts/run-contracts.ts +4 -3
- package/src/core/attach.ts +96 -0
- package/src/core/canonical-scripts.ts +25 -0
- package/src/core/check-ctx.ts +204 -14
- package/src/core/check-file-names.ts +112 -0
- package/src/core/check-pass.ts +84 -0
- package/src/core/check-runner.ts +796 -0
- package/src/core/config-keys.ts +256 -0
- package/src/core/config-rewrite.ts +77 -0
- package/src/core/discover-checks.ts +166 -90
- package/src/core/discover-legacy-checks.ts +26 -0
- package/src/core/doc-block.ts +13 -0
- package/src/core/floor-resolve.ts +264 -0
- package/src/core/floor.ts +20 -137
- package/src/core/git-bin.ts +24 -0
- package/src/core/git-root.ts +108 -0
- package/src/core/instance.ts +195 -0
- package/src/core/jobs.ts +118 -0
- package/src/core/layout.ts +52 -0
- package/src/core/legacy-shapes.ts +207 -0
- package/src/core/lib-root.ts +5 -0
- package/src/core/locked-write.ts +110 -39
- package/src/core/machine-commit.ts +5 -4
- package/src/core/migrate-layout.ts +557 -0
- package/src/core/motherhome.ts +2 -8
- package/src/core/nesting.ts +4 -12
- package/src/core/one-way-in-policy.ts +73 -17
- package/src/core/outcome-words.ts +45 -0
- package/src/core/package-manager.ts +86 -0
- package/src/core/parallel.ts +21 -0
- package/src/core/process-tree.ts +44 -0
- package/src/core/raw-access.ts +93 -0
- package/src/core/read-set.ts +152 -0
- package/src/core/run-check.ts +155 -213
- package/src/core/run-ci.ts +153 -189
- package/src/core/run-fix.ts +96 -0
- package/src/core/run-fixers.ts +53 -30
- package/src/core/self-update.ts +9 -3
- package/src/core/slots.ts +83 -0
- package/src/core/spawn-timeout.ts +34 -3
- package/src/core/when-block.ts +401 -0
- package/src/core/when.ts +135 -0
- package/src/core/write-status.ts +5 -2
- package/src/hooks/leak-guard.ts +10 -8
- package/src/hooks/one-way-in-guard.ts +13 -5
- package/src/hooks/root-identity.ts +2 -1
- package/src/hooks/run-hooks.ts +35 -23
- package/src/init-repo.ts +39 -23
- package/src/ledger/check-stats.ts +155 -0
- package/src/ledger/check-table.ts +179 -0
- package/src/ledger/db.ts +255 -0
- package/src/ledger/gate-checks.ts +433 -0
- package/src/ledger/git-key.ts +116 -77
- package/src/ledger/git-scan.ts +96 -35
- package/src/ledger/ledger.ts +116 -42
- package/src/ledger/live-runs.ts +111 -0
- package/src/ledger/live-timeline.ts +97 -0
- package/src/ledger/repo-view.ts +73 -0
- package/src/ledger/run-log.ts +448 -0
- package/src/ledger/run-timeline.ts +173 -0
- package/src/ledger/store.ts +23 -12
- package/src/ledger/suite-log.ts +161 -0
- package/src/ledger/toolchain-identity.ts +7 -1
- package/src/ledger/world-cache.ts +111 -0
- package/src/mv/mv-md.ts +16 -6
- package/src/problems/extract.ts +182 -0
- package/src/problems/problem-board.ts +37 -0
- package/src/problems/problem-line.ts +77 -0
- package/src/prose/prose-view.ts +90 -0
- package/src/prose/run-prose.ts +60 -19
- package/src/prose/ste.ts +140 -24
- package/src/reflections/reflections-queue.ts +81 -0
- package/src/release/git-facts.ts +15 -9
- package/src/release/run-bump.ts +6 -5
- package/src/release/run-release.ts +25 -11
- package/src/release/run-releases.ts +2 -2
- package/src/release/unit-gate.ts +7 -54
- package/src/release/unit-resolve.ts +282 -0
- package/src/release/units.ts +33 -217
- package/src/repo-config.ts +155 -85
- package/src/rules/rule.ts +36 -96
- package/src/rules/untrack-generated.ts +5 -4
- package/src/scaffold/files/vscode-tasks.json +10 -31
- package/src/scaffold/jsonc-spans.ts +113 -0
- package/src/scaffold/project-scaffold.ts +16 -43
- package/src/scan.ts +98 -0
- package/src/serve/gate-trigger.ts +148 -0
- package/src/serve/health-schedule.ts +102 -0
- package/src/serve/hub-app/chart-view.ts +179 -0
- package/src/serve/hub-app/check-view.tsx +345 -0
- package/src/serve/hub-app/check-words.ts +164 -0
- package/src/serve/hub-app/checks-svc.ts +280 -0
- package/src/serve/hub-app/clock-svc.ts +33 -0
- package/src/serve/hub-app/col-card.tsx +23 -39
- package/src/serve/hub-app/cols-svc.ts +44 -20
- package/src/serve/hub-app/comfy-svc.ts +171 -0
- package/src/serve/hub-app/comfy-view.tsx +81 -0
- package/src/serve/hub-app/dur.ts +41 -0
- package/src/serve/hub-app/hash-route.ts +121 -0
- package/src/serve/hub-app/header-bar.tsx +117 -0
- package/src/serve/hub-app/health-svc.ts +23 -28
- package/src/serve/hub-app/help.tsx +29 -0
- package/src/serve/hub-app/hub-app.tsx +122 -119
- package/src/serve/hub-app/hub-st.ts +115 -42
- package/src/serve/hub-app/hub-types.ts +117 -2
- package/src/serve/hub-app/hub.css +534 -192
- package/src/serve/hub-app/index.html +2 -0
- package/src/serve/hub-app/live-svc.ts +174 -0
- package/src/serve/hub-app/md.ts +15 -0
- package/src/serve/hub-app/media-svc.ts +4 -1
- package/src/serve/hub-app/media-view.tsx +15 -0
- package/src/serve/hub-app/next-run.ts +68 -0
- package/src/serve/hub-app/project-gutter.css +65 -0
- package/src/serve/hub-app/project-gutter.tsx +128 -0
- package/src/serve/hub-app/projects-svc.ts +226 -0
- package/src/serve/hub-app/prose-hits-st.ts +124 -0
- package/src/serve/hub-app/prose-hits-view.tsx +176 -0
- package/src/serve/hub-app/prose-hits.css +21 -0
- package/src/serve/hub-app/run-strip.ts +47 -0
- package/src/serve/hub-app/runs-tabs.ts +24 -0
- package/src/serve/hub-app/test-view.tsx +107 -0
- package/src/serve/hub-app/tests-svc.ts +62 -0
- package/src/serve/hub-app/tests-view.tsx +238 -0
- package/src/serve/hub-app/time-ago.tsx +34 -0
- package/src/serve/hub-app/timeline-chart.tsx +274 -0
- package/src/serve/hub-app/timeline-geometry.ts +314 -0
- package/src/serve/hub-app/timeline-svc.ts +62 -0
- package/src/serve/hub-app/timeline-view.tsx +245 -0
- package/src/serve/hub-app/tree-build.ts +410 -0
- package/src/serve/hub-app/tree-row.tsx +42 -0
- package/src/serve/hub-app/tree-svc.ts +174 -0
- package/src/serve/hub-app/wikis-view.tsx +53 -0
- package/src/serve/other-repos.ts +79 -0
- package/src/serve/project-gutter-route.ts +350 -0
- package/src/serve/project-gutter.ts +449 -0
- package/src/serve/run-serve.ts +245 -16
- package/src/serve/same-origin.ts +14 -0
- package/src/serve/serve-hub.ts +213 -18
- package/src/shipkit.ts +19 -12
- package/src/smoke/run-smoke.ts +3 -2
- package/src/start-repo.ts +75 -36
- package/src/suite/judge-suites.ts +216 -18
- package/src/suite/kept-timings.ts +58 -0
- package/src/suite/runner-shapes.ts +88 -0
- package/src/suite/suite-cli.ts +147 -24
- package/src/suite/suite-cpu-preload.ts +92 -0
- package/src/suite/test-units.ts +243 -0
- package/src/ts/config.ts +4 -69
- package/src/ts/jsonc.ts +69 -0
- package/src/ts/lex.ts +143 -0
- package/src/ts/rename.ts +73 -10
- package/src/ts/source-graph.ts +28 -8
- package/src/ts/tsconfig-projects.ts +6 -97
- package/src/ts/tsconfig-resolve.ts +108 -0
- package/src/tui/build-steps-panel.ts +61 -0
- package/src/tui/checks-panel.ts +24 -16
- package/src/tui/run-tui.ts +2 -2
- package/src/tui/tui-panel.ts +5 -4
- package/src/wiki/graph-colors.ts +68 -0
- package/src/wiki/graph-mount.ts +7 -9
- package/src/wiki/graph-page.ts +42 -25
- package/src/wiki/graph-positions.ts +105 -0
- package/src/wiki/run-wiki.ts +1 -1
- package/src/wiki/serve-graph.ts +33 -3
- package/src/cli/cmd_Rules.ts +0 -51
- package/src/cli/cmd_Units.ts +0 -82
- package/src/collections/graphs.ts +0 -32
- package/src/contracts/contracts-once.ts +0 -43
- package/src/core/check-suite.ts +0 -302
- package/src/core/repair-contract-dirs.ts +0 -136
- package/src/core/task-runner.ts +0 -339
- package/src/ledger/gate-units.ts +0 -138
- package/src/ledger/unit-table.ts +0 -105
- package/src/rules/SK002-contracts-fulfillness.ts +0 -24
- package/src/rules/SK003-claude-md-shape.ts +0 -70
- package/src/rules/SK004-gitignore-policy.ts +0 -238
- package/src/rules/SK010-undeclared-release-unit.ts +0 -127
- package/src/rules/rule-registry.ts +0 -64
- package/src/rules/run-fix.ts +0 -72
- package/src/tui/tasks-panel.ts +0 -60
package/src/wiki/serve-graph.ts
CHANGED
|
@@ -1,18 +1,48 @@
|
|
|
1
1
|
// Local graph server: `load()` runs on EVERY request, so a reload always shows
|
|
2
2
|
// the wiki as it is on disk right now — no stale exports, no build step.
|
|
3
|
+
import { relative } from 'path'
|
|
4
|
+
import { gitRoot } from '../core/git-root.ts'
|
|
5
|
+
import { sameOrigin } from '../serve/same-origin.ts'
|
|
3
6
|
import { graphHtml } from 'rvlib-shipkit/src/wiki/graph-page.ts'
|
|
7
|
+
import { acceptPositions, positionsFile, readPositions, seedPositions } from 'rvlib-shipkit/src/wiki/graph-positions.ts'
|
|
4
8
|
import type { MdGraph } from 'rvlib-shipkit/src/wiki/md-graph.ts'
|
|
5
9
|
|
|
6
|
-
|
|
10
|
+
/** the one path from a graph to its page and back: the hub and the standalone
|
|
11
|
+
* server both mount it. `file` null = no memory, the page never saves */
|
|
12
|
+
export function graphRoutes(o: { load: () => MdGraph; title?: string; file: string | null; saveUrl: string }) {
|
|
13
|
+
return {
|
|
14
|
+
page(): string {
|
|
15
|
+
const g = o.load()
|
|
16
|
+
if (o.file == null) return graphHtml(g, { title: o.title })
|
|
17
|
+
return graphHtml(g, { title: o.title, seed: seedPositions(g, readPositions(o.file)), saveUrl: o.saveUrl })
|
|
18
|
+
},
|
|
19
|
+
save(req: Request): Promise<Response> | Response {
|
|
20
|
+
return o.file == null ? new Response('not found', { status: 404 }) : acceptPositions(req, o.file, o.load)
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ServeMdGraphConfig = { port: number; load: () => MdGraph; title?: string; root?: string }
|
|
26
|
+
|
|
27
|
+
/** outside a git checkout the standalone graph keeps no memory: it has no
|
|
28
|
+
* untracked place to write to */
|
|
29
|
+
export function standalonePositionsFile(root: string): string | null {
|
|
30
|
+
const top = gitRoot(root)?.top
|
|
31
|
+
if (top == null) return null
|
|
32
|
+
return positionsFile(root, `wiki-serve.${relative(top, root) || 'root'}`)
|
|
33
|
+
}
|
|
7
34
|
|
|
8
|
-
// routes: `/` the interactive graph · `/graph.json` the raw graph data
|
|
35
|
+
// routes: `/` the interactive graph · `/graph.json` the raw graph data · `/positions` the one save per display
|
|
9
36
|
export function serveMdGraph(cfg: ServeMdGraphConfig) {
|
|
37
|
+
const routes = graphRoutes({ load: cfg.load, title: cfg.title, file: cfg.root == null ? null : standalonePositionsFile(cfg.root), saveUrl: '/positions' })
|
|
10
38
|
return Bun.serve({
|
|
11
39
|
port: cfg.port,
|
|
12
40
|
fetch(req) {
|
|
13
41
|
const path = new URL(req.url).pathname
|
|
14
|
-
if (
|
|
42
|
+
if (!sameOrigin(req)) return new Response('refused: a request that changes something must come from the graph page', { status: 403 })
|
|
43
|
+
if (path === '/') return new Response(routes.page(), { headers: { 'content-type': 'text/html; charset=utf-8' } })
|
|
15
44
|
if (path === '/graph.json') return Response.json(cfg.load())
|
|
45
|
+
if (path === '/positions') return routes.save(req)
|
|
16
46
|
return new Response('not found', { status: 404 })
|
|
17
47
|
},
|
|
18
48
|
})
|
package/src/cli/cmd_Rules.ts
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
// shipkit rules, render the rule registry: shipkit's built-in SK rules
|
|
2
|
-
// plus whatever the repo registered through `repo.config.ts rules: []`.
|
|
3
|
-
// Agents read ids HERE (or --json), never guess or mint one: the registry
|
|
4
|
-
// is the single source, and a repo whose own invariants are absent from it
|
|
5
|
-
// is a repo whose agents cannot know what binds them.
|
|
6
|
-
import { Command, Option } from 'clipanion'
|
|
7
|
-
import { RULES } from '../rules/rule-registry.ts'
|
|
8
|
-
import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
|
|
9
|
-
|
|
10
|
-
export type RuleRow = { id: string; title: string; owner: string; blocking: boolean; fixable: boolean; source: 'shipkit' | 'repo'; exempt?: string }
|
|
11
|
-
|
|
12
|
-
/** the catalog: built-in rules, then the repo's own */
|
|
13
|
-
export function ruleRows(config: RepoConfig | null): RuleRow[] {
|
|
14
|
-
const row = (source: RuleRow['source']) => (r: { id: string; title: string; owner: string; blocking: boolean; fix?: unknown }): RuleRow =>
|
|
15
|
-
({ id: r.id, title: r.title, owner: r.owner, blocking: r.blocking, fixable: r.fix != null, source, ...(config?.ruleExempt?.[r.id] == null ? {} : { exempt: config.ruleExempt[r.id] }) })
|
|
16
|
-
return [...RULES.map(row('shipkit')), ...(config?.rules ?? []).map(row('repo'))]
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export class RulesCommand extends Command {
|
|
20
|
-
static override paths = [['rules']]
|
|
21
|
-
|
|
22
|
-
static override usage = Command.Usage({
|
|
23
|
-
category: 'Health',
|
|
24
|
-
description: 'every rule that runs here: whether it BLOCKS or only warns, what it lints, the owning doc, and whether --fix can repair it. Built-in SK rules plus the repo\'s own.',
|
|
25
|
-
examples: [
|
|
26
|
-
['list rules', 'shipkit rules'],
|
|
27
|
-
['machine-readable', 'shipkit rules --json'],
|
|
28
|
-
],
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
json = Option.Boolean('--json', false, { description: 'machine-readable [{id, title, owner, blocking, fixable, source}]' })
|
|
32
|
-
|
|
33
|
-
async execute(): Promise<number> {
|
|
34
|
-
// a repo without repo.config.ts still gets the built-in catalog: this
|
|
35
|
-
// command must answer "what rules exist" from anywhere
|
|
36
|
-
const config = await loadRepoConfig(process.cwd()).catch(() => null)
|
|
37
|
-
const repoRules = config?.rules ?? []
|
|
38
|
-
const rows = ruleRows(config)
|
|
39
|
-
if (this.json) {
|
|
40
|
-
this.context.stdout.write(`${JSON.stringify(rows)}\n`)
|
|
41
|
-
return 0
|
|
42
|
-
}
|
|
43
|
-
// a catalog, not a health run — no wire prefixes (`ok:` would assert
|
|
44
|
-
// a passing check into anything that parses wire output)
|
|
45
|
-
for (const r of rows)
|
|
46
|
-
this.context.stdout.write(`${r.blocking ? 'BLOCKS ' : 'warns '}${r.id} ${r.title} — ${r.owner}${r.fixable ? ' · fixable' : ''}${r.exempt == null ? '' : ` · EXEMPT here: ${r.exempt}`}\n`)
|
|
47
|
-
if (repoRules.length > 0)
|
|
48
|
-
this.context.stdout.write(`\n${RULES.length} built-in · ${repoRules.length} repo-local (repo.config.ts rules)\n`)
|
|
49
|
-
return 0
|
|
50
|
-
}
|
|
51
|
-
}
|
package/src/cli/cmd_Units.ts
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
// shipkit units — THE inventory: every task, rule and discovered check on one
|
|
2
|
-
// table, with what it declared about itself. `shipkit rules` covers a third of
|
|
3
|
-
// it, `shipkit keys` answers a different question (what is a unit keyed on,
|
|
4
|
-
// right now), and neither says whether a unit blocks you or only reports the
|
|
5
|
-
// world. That answer had no surface, so it was reconstructed by reading source
|
|
6
|
-
// files, which is how a repo ends up with checks nobody can account for.
|
|
7
|
-
import { writeFileSync } from 'fs'
|
|
8
|
-
import { resolve } from 'path'
|
|
9
|
-
import { Command, Option } from 'clipanion'
|
|
10
|
-
import { loadRepoConfig } from '../repo-config.ts'
|
|
11
|
-
import { envTier, gateInventory, tieredTasks } from '../ledger/gate-units.ts'
|
|
12
|
-
import { renderUnitTable } from '../ledger/unit-table.ts'
|
|
13
|
-
import { classify } from '../ledger/git-key.ts'
|
|
14
|
-
|
|
15
|
-
export class UnitsCommand extends Command {
|
|
16
|
-
static override paths = [['units']]
|
|
17
|
-
|
|
18
|
-
static override usage = Command.Usage({
|
|
19
|
-
category: 'Health',
|
|
20
|
-
description: 'every gate unit on one table: task, rule or check, whether it blocks or only warns, whether the gate runs it or only `shipkit check`, and how it caches. Reports, never gates',
|
|
21
|
-
examples: [
|
|
22
|
-
['the whole inventory', 'shipkit units'],
|
|
23
|
-
['the big markdown table, written to a file', 'shipkit units --out STATUS.full.md'],
|
|
24
|
-
['only the ones the gate never runs', 'shipkit units --proactive'],
|
|
25
|
-
['machine-readable', 'shipkit units --json'],
|
|
26
|
-
],
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
json = Option.Boolean('--json', false, { description: 'machine-readable UnitRow[] with the resolved cache population' })
|
|
30
|
-
md = Option.Boolean('--md', false, { description: 'the full markdown table on stdout, the same bytes --out writes' })
|
|
31
|
-
out = Option.String('--out', { required: false, description: 'write the markdown table to this file instead of stdout' })
|
|
32
|
-
blocking = Option.Boolean('--blocking', false, { description: 'only units whose failure can stop you' })
|
|
33
|
-
proactive = Option.Boolean('--proactive', false, { description: 'only units the gate never runs (`shipkit check` reports them)' })
|
|
34
|
-
full = Option.Boolean('--full', false, { description: 'resolve tasks the way a FULL-tier run resolves them' })
|
|
35
|
-
|
|
36
|
-
async execute(): Promise<number> {
|
|
37
|
-
const root = process.cwd()
|
|
38
|
-
const c = await loadRepoConfig(root)
|
|
39
|
-
const tier = this.full ? 'full' : envTier()
|
|
40
|
-
let rows = gateInventory(root, c, tieredTasks(c, root, tier))
|
|
41
|
-
if (this.blocking) rows = rows.filter((r) => r.blocking !== false)
|
|
42
|
-
if (this.proactive) rows = rows.filter((r) => !r.gate)
|
|
43
|
-
|
|
44
|
-
if (this.json) {
|
|
45
|
-
this.context.stdout.write(`${JSON.stringify(rows.map((r) => ({ ...r, population: classify(r.decl).population })))}\n`)
|
|
46
|
-
return 0
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
if (this.md || this.out != null) {
|
|
50
|
-
const md = renderUnitTable({ name: c.name, rows })
|
|
51
|
-
if (this.out != null) {
|
|
52
|
-
const target = resolve(root, this.out)
|
|
53
|
-
writeFileSync(target, `${md.trimEnd()}\n`)
|
|
54
|
-
this.context.stdout.write(`ok: ${rows.length} unit(s) written to ${this.out}\n`)
|
|
55
|
-
return 0
|
|
56
|
-
}
|
|
57
|
-
this.context.stdout.write(`${md}\n`)
|
|
58
|
-
return 0
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// a catalog, not a health run — no wire prefixes, or anything parsing
|
|
62
|
-
// wire output reads this as a passing check
|
|
63
|
-
this.context.stdout.write(`${'kind'.padEnd(5)} ${'unit'.padEnd(28)} ${'runs in'.padEnd(9)} ${'failure'.padEnd(10)} ${'cache'.padEnd(11)} ttl\n`)
|
|
64
|
-
for (const r of rows) {
|
|
65
|
-
const cls = classify(r.decl)
|
|
66
|
-
const cache = cls.error != null ? 'BROKEN' : cls.population === 'none' ? (cls.decided === true ? 'never' : 'UNDECLARED') : cls.population
|
|
67
|
-
const blocking = r.blocking == null ? 'UNDECLARED' : r.blocking ? 'blocks' : 'warns'
|
|
68
|
-
const runsIn = r.optIn ? 'opt-in' : r.gate ? 'gate' : 'proactive'
|
|
69
|
-
this.context.stdout.write(
|
|
70
|
-
`${r.decl.kind.padEnd(5)} ${r.decl.id.padEnd(28)} ${runsIn.padEnd(9)} ${blocking.padEnd(10)} ${cache.padEnd(11)} ${r.decl.ttl ?? ''}\n`,
|
|
71
|
-
)
|
|
72
|
-
}
|
|
73
|
-
const undeclared = rows.filter((r) => { const cl = classify(r.decl); return cl.population === 'none' && cl.decided !== true }).length
|
|
74
|
-
const proactive = rows.filter((r) => !r.gate && !r.optIn).length
|
|
75
|
-
this.context.stdout.write(
|
|
76
|
-
`\n${rows.length} units — ${rows.length - proactive} in the gate, ${proactive} proactive · ${undeclared} still uncacheable with nobody having looked\n`,
|
|
77
|
-
)
|
|
78
|
-
if (undeclared > 0) this.context.stdout.write(` why each one: shipkit keys --burn-down\n`)
|
|
79
|
-
this.context.stdout.write(` the full table, refreshed by every gate run: STATUS.full.md\n`)
|
|
80
|
-
return 0
|
|
81
|
-
}
|
|
82
|
-
}
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
// graphs collection — the MOUNTED md graphs as CHECKED section entries. The
|
|
2
|
-
// mounts arrive as data from the hub's own route resolution (serve/run-serve),
|
|
3
|
-
// never re-derived here: a card linking at an unmounted route renders the 404
|
|
4
|
-
// body inside its iframe. Each instance carries its lint state (orphans +
|
|
5
|
-
// dangling refs = yellow with counts; clean = quiet). A graph whose lint
|
|
6
|
-
// belongs to another repo is link-only, never double-judged here.
|
|
7
|
-
import { lintMd } from '../wiki/lint-md.ts'
|
|
8
|
-
import type { GraphMount } from '../wiki/graph-mount.ts'
|
|
9
|
-
import type { Collection, Instance } from './collection-types.ts'
|
|
10
|
-
|
|
11
|
-
export function graphsCollection(mounts: GraphMount[]): Collection {
|
|
12
|
-
return {
|
|
13
|
-
id: 'graphs',
|
|
14
|
-
title: 'graphs',
|
|
15
|
-
media: 'data',
|
|
16
|
-
list: async () =>
|
|
17
|
-
mounts.map((m): Instance => {
|
|
18
|
-
const id = `graph:${m.id}`
|
|
19
|
-
const href = `/graph/${m.id}`
|
|
20
|
-
const label = m.note != null ? `${m.label} (${m.note})` : m.label
|
|
21
|
-
if (m.lint == null) return { id, text: label, href }
|
|
22
|
-
const r = lintMd({ root: m.lint.root, index: m.lint.index })
|
|
23
|
-
const issues = r.orphans.length + r.dangling.length
|
|
24
|
-
return {
|
|
25
|
-
id,
|
|
26
|
-
text: issues === 0 ? label : `${label} — ${r.orphans.length} orphan(s), ${r.dangling.length} dangling ref(s)`,
|
|
27
|
-
severity: issues === 0 ? undefined : 'yellow',
|
|
28
|
-
href,
|
|
29
|
-
}
|
|
30
|
-
}),
|
|
31
|
-
}
|
|
32
|
-
}
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
// ONE contract evaluation per gate run.
|
|
2
|
-
//
|
|
3
|
-
// Two surfaces report fulfillness inside a single `shipkit ci`: the `contracts`
|
|
4
|
-
// TASK, which exists because deploy tasks declare `deps: ['contracts']` and so
|
|
5
|
-
// need a real ordering edge in the DAG, and rule SK002, which exists because a
|
|
6
|
-
// `shipkit check` with no task phase must still report it. Both are load
|
|
7
|
-
// bearing and neither can be deleted: dropping the task would silently ungate
|
|
8
|
-
// every deploy that depends on it, since checks run AFTER every task.
|
|
9
|
-
//
|
|
10
|
-
// So they stay two REPORTS of one evaluation. The task runs first and its
|
|
11
|
-
// result is what SK002 reads. THE TASK IS AUTHORITATIVE (contracts.md says so
|
|
12
|
-
// too); SK002 evaluating on its own is the fallback for a run with no task
|
|
13
|
-
// phase, not a second opinion.
|
|
14
|
-
//
|
|
15
|
-
// The scope is EXPLICIT and never ambient. A memo that outlived its run would
|
|
16
|
-
// let the hub, which rebuilds its collection list on every request, answer
|
|
17
|
-
// from a snapshot of whatever the last gate saw. Only a caller that owns a run
|
|
18
|
-
// opens one.
|
|
19
|
-
import { evaluateContracts, type ContractsConfig, type ContractsResult } from './contracts-engine.ts'
|
|
20
|
-
import type { ContractManifest } from './contract-types.ts'
|
|
21
|
-
|
|
22
|
-
let open = false
|
|
23
|
-
let cached: { key: string; result: Promise<ContractsResult> } | null = null
|
|
24
|
-
|
|
25
|
-
/** run `fn` as ONE gate run: contract evaluation inside it happens once. */
|
|
26
|
-
export async function withContractsScope<T>(fn: () => Promise<T>): Promise<T> {
|
|
27
|
-
const outer = open
|
|
28
|
-
open = true
|
|
29
|
-
try { return await fn() } finally { if (!outer) { open = false; cached = null } }
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** the evaluation, memoised only while a run scope is open. Outside one it is
|
|
33
|
-
* a plain call, so `shipkit contracts` and the hub always measure reality. */
|
|
34
|
-
export function evaluateContractsOnce(manifests: ContractManifest[], cfg: ContractsConfig): Promise<ContractsResult> {
|
|
35
|
-
if (!open) return evaluateContracts(manifests, cfg)
|
|
36
|
-
// the config decides the verdict, so it belongs in the identity: a run that
|
|
37
|
-
// somehow evaluated two different configs must not serve one for the other
|
|
38
|
-
const key = JSON.stringify(cfg)
|
|
39
|
-
if (cached != null && cached.key === key) return cached.result
|
|
40
|
-
const result = evaluateContracts(manifests, cfg)
|
|
41
|
-
cached = { key, result }
|
|
42
|
-
return result
|
|
43
|
-
}
|
package/src/core/check-suite.ts
DELETED
|
@@ -1,302 +0,0 @@
|
|
|
1
|
-
// CheckSuite — declare tools / secrets / checks, then `await suite.run()`;
|
|
2
|
-
// renders the v0 wire contract (wire.ts) with exit code.
|
|
3
|
-
//
|
|
4
|
-
// The TTL cache is the API-spam guard: a passing check with `remainValidFor`
|
|
5
|
-
// replays without touching the network. Only clean passes are cached;
|
|
6
|
-
// WARN/FAIL evict. Busts: version bump, secret rotation (key hashes the
|
|
7
|
-
// secret value), `<freshEnv>=1`.
|
|
8
|
-
//
|
|
9
|
-
// STORAGE is not this module's concern any more: ledger/store.ts owns the
|
|
10
|
-
// one store, its shape, and its housekeeping (one immutable file per key,
|
|
11
|
-
// tmp + rename, age-based sweep). This is the WORLD half of the three
|
|
12
|
-
// populations — an answer nothing on disk describes, remembered for a TTL and
|
|
13
|
-
// busted by rotation. The CONTENT half is keyed instead of timed and lives in
|
|
14
|
-
// ledger/ledger.ts. Same store, different key.
|
|
15
|
-
//
|
|
16
|
-
// Secret VALUES never enter this module's state beyond the injected
|
|
17
|
-
// getSecret call + a truncated hash for cache-busting.
|
|
18
|
-
import { existsSync } from 'fs'
|
|
19
|
-
import { CheckCtx, RateLimited, ToolUnresolved } from './check-ctx.ts'
|
|
20
|
-
import { dropStored, entryPath, FRESH_ENV, readStored, sweepStore, writeStored } from '../ledger/store.ts'
|
|
21
|
-
import { fmtStamp, formatLine, parseDuration, type Line } from './wire.ts'
|
|
22
|
-
|
|
23
|
-
type Logic = (t: CheckCtx) => unknown
|
|
24
|
-
type Prep = { skip?: string; cacheExtra?: string }
|
|
25
|
-
type Runner = { name: string; ttlMs: number; version: number; prep?: () => Prep; logic: Logic; blocking: boolean }
|
|
26
|
-
|
|
27
|
-
/** the longest a MEASURED NEGATIVE is remembered, whatever TTL the unit
|
|
28
|
-
* declared. Long enough to stop a re-ask storm inside one working session,
|
|
29
|
-
* short enough that the world being fixed is noticed the same day. A clean
|
|
30
|
-
* answer is not capped: "fine" is what a long TTL is for. */
|
|
31
|
-
const NEGATIVE_TTL_CAP_MS = 60 * 60 * 1000
|
|
32
|
-
|
|
33
|
-
export type GetSecret = (name: string, field: 'get' | 'account') => string | null
|
|
34
|
-
|
|
35
|
-
export type SecretSpec = {
|
|
36
|
-
remainValidFor?: string | number
|
|
37
|
-
version?: number
|
|
38
|
-
/** appended to the unstored skip message — a POINTER to where to mint (URL, file, or doc section), never inline mint steps */
|
|
39
|
-
skipHint: string
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export type CheckOutcome = { lines: Line[]; status: 'pass' | 'fail' | 'warn' | 'skip'; cached: boolean; /** wall time of the LOGIC, ms — 0 on a replay, since a replay measured nothing */ ms: number }
|
|
43
|
-
|
|
44
|
-
export type CheckSuiteOpts = {
|
|
45
|
-
name: string
|
|
46
|
-
/** the ONE store root (ledger/store.ts storeRoot). Injected rather than
|
|
47
|
-
* resolved here so a test can point a suite at a scratch dir. */
|
|
48
|
-
storeDir: string
|
|
49
|
-
now?: () => number
|
|
50
|
-
/** secret backend — consumers inject their own store; default = nothing stored, every secret check skips */
|
|
51
|
-
getSecret?: GetSecret
|
|
52
|
-
/** TEST SEAM: the env var that busts this suite's replays. Production is
|
|
53
|
-
* always the one house-wide flag (ledger/store.ts FRESH_ENV); a test
|
|
54
|
-
* overrides it so busting one suite cannot poison a neighbour's run. */
|
|
55
|
-
freshEnv?: string
|
|
56
|
-
/** per-check completion tap (the TUI streams results in) — cached replays
|
|
57
|
-
* report cached:true, prep-skips (unstored secret) report status 'skip' */
|
|
58
|
-
onResult?: (name: string, r: CheckOutcome) => void
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export class CheckSuite {
|
|
62
|
-
readonly name: string
|
|
63
|
-
private runners: Runner[] = []
|
|
64
|
-
private toolPaths = new Map<string, string>()
|
|
65
|
-
private storeDir: string
|
|
66
|
-
private now: () => number
|
|
67
|
-
private getSecret: GetSecret
|
|
68
|
-
private freshEnv: string
|
|
69
|
-
private onResult?: (name: string, r: CheckOutcome) => void
|
|
70
|
-
|
|
71
|
-
constructor(opts: CheckSuiteOpts) {
|
|
72
|
-
this.name = opts.name
|
|
73
|
-
this.storeDir = opts.storeDir
|
|
74
|
-
this.now = opts.now ?? Date.now
|
|
75
|
-
this.getSecret = opts.getSecret ?? (() => null)
|
|
76
|
-
this.freshEnv = opts.freshEnv ?? FRESH_ENV
|
|
77
|
-
this.onResult = opts.onResult
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/** `blocking` decides what a NEGATIVE result may be cached as: see the
|
|
81
|
-
* measured-negative rule in execute(). Default true, the safe side: a
|
|
82
|
-
* check that never declared itself is treated as being about this repo,
|
|
83
|
-
* so its failures are never replayed. */
|
|
84
|
-
addCheck(spec: { name: string; remainValidFor?: string | number; version?: number; cacheExtra?: () => string; logic: Logic; blocking?: boolean }): void {
|
|
85
|
-
this.runners.push({ name: spec.name, ttlMs: spec.remainValidFor != null ? parseDuration(spec.remainValidFor) : 0, version: spec.version ?? 1, prep: spec.cacheExtra != null ? () => ({ cacheExtra: spec.cacheExtra!() }) : undefined, logic: spec.logic, blocking: spec.blocking ?? true })
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
defineRequiredTool(name: string, opts: { candidates?: string[]; probe?: { args: string[]; expect?: RegExp }; fix: string }): void {
|
|
89
|
-
this.addCheck({
|
|
90
|
-
name,
|
|
91
|
-
logic: async (t) => {
|
|
92
|
-
// explicit candidate paths beat ambient PATH — a stale global (e.g. tsc 4.x) must not shadow the pinned one
|
|
93
|
-
const bin = opts.candidates?.find((c) => existsSync(c)) ?? Bun.which(name)
|
|
94
|
-
if (bin == null) return t.fail(`${name} not installed`, opts.fix)
|
|
95
|
-
this.toolPaths.set(name, bin)
|
|
96
|
-
if (opts.probe == null) return t.ok(`${name} present`)
|
|
97
|
-
const p = await t.exec([bin, ...opts.probe.args])
|
|
98
|
-
const first = p.out.trim().split('\n')[0] ?? ''
|
|
99
|
-
if (p.code !== 0 || (opts.probe.expect != null && !opts.probe.expect.test(p.out.trim())))
|
|
100
|
-
return t.fail(`${name} present but broken (${first.slice(0, 120)})`, opts.fix)
|
|
101
|
-
// version-ish summary, not the whole banner (some CLIs append build date + ref)
|
|
102
|
-
t.ok(`${name} ${(first.split(',')[0] ?? first).slice(0, 80)}`)
|
|
103
|
-
},
|
|
104
|
-
})
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
defineRequiredSecret(spec: SecretSpec & { name: string; account?: false; validate: (value: string, t: CheckCtx) => unknown }): void
|
|
108
|
-
defineRequiredSecret(spec: SecretSpec & { name: string; account: true; validate: (cred: { value: string; account: string }, t: CheckCtx) => unknown }): void
|
|
109
|
-
defineRequiredSecret(spec: SecretSpec & { name: string[]; validate: (values: string[], t: CheckCtx) => unknown }): void
|
|
110
|
-
defineRequiredSecret(spec: SecretSpec & { name: string | string[]; account?: boolean; validate: (v: never, t: CheckCtx) => unknown }): void {
|
|
111
|
-
const names = [spec.name].flat()
|
|
112
|
-
const primary = names[0]
|
|
113
|
-
if (primary == null) throw new Error('defineRequiredSecret: name must be non-empty')
|
|
114
|
-
// probes may run without typechecking — enforce the required pointer at define time too
|
|
115
|
-
if (typeof spec.skipHint !== 'string' || spec.skipHint.length === 0)
|
|
116
|
-
throw new Error(`defineRequiredSecret(${primary}): skipHint is required — a pointer to where to mint (URL, file, or doc section)`)
|
|
117
|
-
const validate = spec.validate as (v: unknown, t: CheckCtx) => unknown
|
|
118
|
-
let payload: unknown
|
|
119
|
-
this.runners.push({
|
|
120
|
-
// conservative on purpose: a secret probe keeps today's behaviour,
|
|
121
|
-
// where no negative is remembered. Its cache key already hashes the
|
|
122
|
-
// secret VALUE, so a rotation busts it, which would make caching a
|
|
123
|
-
// measured 403 sound. That is a severity call for whoever owns the
|
|
124
|
-
// secrets surface, not a side effect of this change.
|
|
125
|
-
blocking: true,
|
|
126
|
-
// bare secret name — this shows up in human-facing WARN/FAIL lines
|
|
127
|
-
name: names.length === 1 ? primary : primary.split('/').slice(0, 2).join('/') + '/*',
|
|
128
|
-
ttlMs: spec.remainValidFor != null ? parseDuration(spec.remainValidFor) : 0,
|
|
129
|
-
version: spec.version ?? 1,
|
|
130
|
-
prep: () => {
|
|
131
|
-
const values = names.map((n) => this.getSecret(n, 'get'))
|
|
132
|
-
const account = spec.account === true ? this.getSecret(primary, 'account') : undefined
|
|
133
|
-
const missing = names.filter((_, i) => values[i] == null)
|
|
134
|
-
if (spec.account === true && account == null) missing.push(`${primary} (account field)`)
|
|
135
|
-
if (missing.length > 0)
|
|
136
|
-
return { skip: `${missing.join(', ')} unstored — ${spec.skipHint}` }
|
|
137
|
-
payload = spec.account === true ? { value: values[0], account } : Array.isArray(spec.name) ? values : values[0]
|
|
138
|
-
// secret rotation busts the cache: key = hash of the value(s)
|
|
139
|
-
const material = [...(values as string[]), account ?? ''].join('\0')
|
|
140
|
-
return { cacheExtra: new Bun.CryptoHasher('sha256').update(material).digest('hex').slice(0, 16) }
|
|
141
|
-
},
|
|
142
|
-
logic: (t) => validate(payload, t),
|
|
143
|
-
})
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** the key a world answer is remembered under: the check's own version
|
|
147
|
-
* plus whatever the prep declared (a secret's value hash, a rule's
|
|
148
|
-
* cacheKey). A version bump and a rotation both move it, so neither can
|
|
149
|
-
* replay across the change. */
|
|
150
|
-
private cacheKey(r: Runner, extra: string): string {
|
|
151
|
-
return `v${r.version}\0${extra}`
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// test seam — run() prints + exits; execute() returns the structured result
|
|
155
|
-
/** ONE check: cache replay, run, classify, and what it wants remembered.
|
|
156
|
-
* Pure with respect to its neighbours, which is what lets execute() run
|
|
157
|
-
* them together. */
|
|
158
|
-
private async runOne(r: Runner): Promise<{ lines: Line[]; skip: string | null; ran: boolean; failed: boolean }> {
|
|
159
|
-
const prep = r.prep?.() ?? {}
|
|
160
|
-
if (prep.skip != null) {
|
|
161
|
-
this.onResult?.(r.name, { lines: [{ kind: 'skip', text: prep.skip }], status: 'skip', cached: false, ms: 0 })
|
|
162
|
-
return { lines: [], skip: prep.skip, ran: false, failed: false }
|
|
163
|
-
}
|
|
164
|
-
const key = this.cacheKey(r, prep.cacheExtra ?? '')
|
|
165
|
-
const path = entryPath(this.storeDir, this.name, r.name, key)
|
|
166
|
-
const entry = readStored<Line[]>(path, key)
|
|
167
|
-
// an entry may keep for LESS than the declared TTL: a measured negative
|
|
168
|
-
// writes its own shorter window (below), so the run after a fix sees
|
|
169
|
-
// the fix rather than the memory of the breakage
|
|
170
|
-
const keeps = entry?.keeps ?? r.ttlMs
|
|
171
|
-
const fresh = r.ttlMs > 0 && process.env[this.freshEnv] == null
|
|
172
|
-
&& entry != null && this.now() - entry.at < keeps && this.now() >= entry.at
|
|
173
|
-
if (fresh) {
|
|
174
|
-
// the stamp was baked in when the check RAN (below): a replay
|
|
175
|
-
// prints the stored lines VERBATIM, so the live run and every
|
|
176
|
-
// later replay render the same string, and the line changes
|
|
177
|
-
// only when a re-validation happens. Annotating replays but
|
|
178
|
-
// not live runs still diffed on every fresh-vs-cached
|
|
179
|
-
// alternation, so neither side annotates
|
|
180
|
-
// a replayed WORLD negative keeps its severity: it is still the
|
|
181
|
-
// answer, and demoting it to a pass would hide a real finding
|
|
182
|
-
const replayedWarn = entry.payload.some((l) => l.kind === 'warn' || l.kind === 'fail')
|
|
183
|
-
this.onResult?.(r.name, { lines: entry.payload, status: replayedWarn ? 'warn' : 'pass', cached: true, ms: 0 })
|
|
184
|
-
return { lines: entry.payload, skip: null, ran: true, failed: false }
|
|
185
|
-
}
|
|
186
|
-
const t = new CheckCtx(this.toolPaths)
|
|
187
|
-
const started = performance.now()
|
|
188
|
-
try {
|
|
189
|
-
await r.logic(t)
|
|
190
|
-
} catch (e) {
|
|
191
|
-
// both of these mean the probe never reached its subject, so the
|
|
192
|
-
// run has no answer to remember
|
|
193
|
-
if (e instanceof RateLimited) t.unreachable(`${r.name} not validated this run (${e.message}) — retry later`)
|
|
194
|
-
else if (e instanceof ToolUnresolved) { t.unmeasured = true; t.note(`${r.name} skipped (${e.message})`) }
|
|
195
|
-
else t.fail(`${r.name} errored (${String(e).slice(0, 200)}) — network down, or a bug in this check`)
|
|
196
|
-
}
|
|
197
|
-
// AN ANSWER CACHES. THE ABSENCE OF ONE DOES NOT. That distinction, not
|
|
198
|
-
// pass-versus-fail, is what decides cacheability here, and a future
|
|
199
|
-
// reader is warned off "fixing" the asymmetry:
|
|
200
|
-
//
|
|
201
|
-
// measured negative a probe that RAN and came back bad ("the box is
|
|
202
|
-
// off", "the token answered 403", "five repos are
|
|
203
|
-
// uncatalogued") is a real answer about the world.
|
|
204
|
-
// Replaying it is CORRECT for a WHILE: the world
|
|
205
|
-
// does not fix itself between two gate runs, and
|
|
206
|
-
// re-asking every 30 seconds spends a third-party
|
|
207
|
-
// API call to be told the same thing. It is NOT
|
|
208
|
-
// correct for the full TTL, and that asymmetry is
|
|
209
|
-
// deliberate: a clean pass says the world is fine,
|
|
210
|
-
// which a long TTL is exactly right for, while a
|
|
211
|
-
// negative names something a HUMAN is expected to
|
|
212
|
-
// go fix, and the fix is invisible from here. A
|
|
213
|
-
// 100d TTL over a revoked token would show the
|
|
214
|
-
// same red for 100 days after it was re-minted,
|
|
215
|
-
// because the token value is not in this key. So a
|
|
216
|
-
// negative keeps for NEGATIVE_TTL_CAP at most.
|
|
217
|
-
// unmeasured a probe that could not run at all ("registry
|
|
218
|
-
// unreachable", "no cred", "rate limited", tool
|
|
219
|
-
// missing) produced no answer. Caching it asserts
|
|
220
|
-
// a dead network as fact long after it recovered.
|
|
221
|
-
// `skip` lines and the unmeasured flag mark these.
|
|
222
|
-
//
|
|
223
|
-
// A measured negative caches ONLY for a check about the WORLD. A
|
|
224
|
-
// blocking check reports THIS REPO, where the whole point is that a
|
|
225
|
-
// human fixes it and the next run goes green: replaying its red for a
|
|
226
|
-
// TTL would be the cache lying about work already done.
|
|
227
|
-
const unmeasured = t.unmeasured || t.lines.some((l) => l.kind === 'skip')
|
|
228
|
-
const clean = !t.failed && !t.warned
|
|
229
|
-
const cacheable = r.ttlMs > 0 && !unmeasured && (clean || !r.blocking)
|
|
230
|
-
// a clean answer keeps for the whole declared TTL. a negative keeps
|
|
231
|
-
// only long enough to stop the re-ask storm, so the run after a human
|
|
232
|
-
// fixes the world sees the fix instead of the memory of the breakage
|
|
233
|
-
const keepsFor = clean ? r.ttlMs : Math.min(r.ttlMs, NEGATIVE_TTL_CAP_MS)
|
|
234
|
-
const lines = cacheable
|
|
235
|
-
? t.lines.map((l, i): Line => (i === 0 ? { ...l, text: `${l.text} (${clean ? 'cached' : 'measured'} at ${fmtStamp(this.now())} until ${fmtStamp(this.now() + keepsFor)})` } : l))
|
|
236
|
-
: t.lines
|
|
237
|
-
this.onResult?.(r.name, { lines, status: t.failed ? 'fail' : t.warned ? 'warn' : 'pass', cached: false, ms: performance.now() - started })
|
|
238
|
-
// this check writes its OWN file, and only its own: nothing here reads
|
|
239
|
-
// or rewrites a neighbour's entry, so a racing runner can lose nothing
|
|
240
|
-
if (r.ttlMs > 0) {
|
|
241
|
-
if (cacheable) writeStored(path, key, lines, this.now(), keepsFor)
|
|
242
|
-
// an unmeasured run evicts whatever was stored: a later run must
|
|
243
|
-
// re-probe rather than replay an answer recorded before the outage.
|
|
244
|
-
// A racing runner may have written a good answer into that same
|
|
245
|
-
// file a moment ago and lose it here, which costs one re-probe and
|
|
246
|
-
// can never produce a stale green, so it is the safe direction.
|
|
247
|
-
else if (entry != null) dropStored(path)
|
|
248
|
-
}
|
|
249
|
-
return { lines, skip: null, ran: true, failed: t.failed }
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
async execute(): Promise<{ lines: Line[]; exitCode: number }> {
|
|
253
|
-
sweepStore(this.storeDir, this.name, this.now())
|
|
254
|
-
|
|
255
|
-
// CONCURRENT, bounded. Checks are independent by contract: each answers
|
|
256
|
-
// one question about the world or the repo and shares nothing with its
|
|
257
|
-
// neighbours, and most of the wall time is a network round trip or a
|
|
258
|
-
// cold `bun` start. In series, twenty checks cost the SUM; together
|
|
259
|
-
// they cost the slowest one. Measured on this repo: 18.4s to 6.0s.
|
|
260
|
-
// Order is preserved by collecting per runner and concatenating in
|
|
261
|
-
// declaration order, so the wire output is byte-identical to the
|
|
262
|
-
// sequential run and the frozen contract still holds.
|
|
263
|
-
const cap = Math.max(2, Math.min(8, navigator.hardwareConcurrency || 4))
|
|
264
|
-
const results = new Array<Awaited<ReturnType<CheckSuite['runOne']>>>(this.runners.length)
|
|
265
|
-
let next = 0
|
|
266
|
-
const worker = async (): Promise<void> => {
|
|
267
|
-
for (;;) {
|
|
268
|
-
const i = next++
|
|
269
|
-
if (i >= this.runners.length) return
|
|
270
|
-
results[i] = await this.runOne(this.runners[i]!)
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
await Promise.all(Array.from({ length: Math.min(cap, this.runners.length) }, () => worker()))
|
|
274
|
-
|
|
275
|
-
const out: Line[] = []
|
|
276
|
-
const skipped: string[] = []
|
|
277
|
-
let ran = 0
|
|
278
|
-
let anyFail = false
|
|
279
|
-
for (let i = 0; i < this.runners.length; i++) {
|
|
280
|
-
const r = results[i]!
|
|
281
|
-
out.push(...r.lines)
|
|
282
|
-
if (r.skip != null) skipped.push(r.skip)
|
|
283
|
-
if (r.ran) ran++
|
|
284
|
-
if (r.failed) anyFail = true
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// contract: `skip:` lines ONLY when no check ran at all (whole probe is
|
|
288
|
-
// moot without the creds); otherwise a neutral note so passing probes
|
|
289
|
-
// are not relabeled 'check skip'
|
|
290
|
-
if (ran === 0) out.push(...skipped.map((s): Line => ({ kind: 'skip', text: `${s} (creds gate reports it)` })))
|
|
291
|
-
else out.push(...skipped.map((s): Line => ({ kind: 'note', text: `${s} — validation skipped (creds gate reports it)` })))
|
|
292
|
-
|
|
293
|
-
return { lines: out, exitCode: anyFail ? 1 : 0 }
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
async run(): Promise<never> {
|
|
297
|
-
const { lines, exitCode } = await this.execute()
|
|
298
|
-
const color = process.stdout.isTTY && process.env.NO_COLOR == null
|
|
299
|
-
for (const l of lines) console.log(formatLine(l, color))
|
|
300
|
-
process.exit(exitCode)
|
|
301
|
-
}
|
|
302
|
-
}
|