rvlib-shipkit 0.33.0 → 0.35.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 +23 -1
- package/package.json +1 -1
- package/src/asks/asks-queue.ts +15 -2
- package/src/cli/cmd_Asks.ts +2 -1
- package/src/cli/cmd_Ci.ts +3 -0
- package/src/cli/cmd_Keys.ts +113 -0
- package/src/cli.ts +2 -0
- package/src/core/check-suite.ts +54 -47
- package/src/core/discover-checks.ts +30 -7
- package/src/core/floor.ts +34 -6
- package/src/core/locked-write.ts +170 -1
- package/src/core/motherhome.ts +54 -1
- package/src/core/one-way-in-policy.ts +8 -8
- package/src/core/run-check.ts +92 -12
- package/src/core/run-ci.ts +115 -17
- package/src/core/run-fixers.ts +61 -18
- package/src/core/task-runner.ts +62 -6
- package/src/hooks/run-hooks.ts +23 -2
- package/src/ledger/gate-units.ts +84 -0
- package/src/ledger/git-key.ts +381 -0
- package/src/ledger/git-scan.ts +327 -0
- package/src/ledger/ledger.ts +202 -0
- package/src/ledger/store.ts +122 -0
- package/src/ledger/toolchain-identity.ts +45 -8
- package/src/pb/collection-scaffold.ts +4 -4
- package/src/release/run-bump.ts +50 -5
- package/src/release/run-release.ts +5 -0
- package/src/release/run-releases.ts +7 -1
- package/src/release/unit-gate.ts +17 -4
- package/src/repo-config.ts +26 -4
- package/src/rules/SK001-package-json-shape.ts +8 -2
- package/src/rules/SK002-contracts-fulfillness.ts +4 -0
- package/src/rules/SK003-claude-md-shape.ts +7 -2
- package/src/rules/SK004-gitignore-policy.ts +12 -4
- package/src/rules/SK005-rvlib-versions.ts +28 -1
- package/src/rules/SK006-npm-publish-safety.ts +3 -0
- package/src/rules/SK008-reviewer-agent.ts +6 -2
- package/src/rules/SK009-commit-ai-tells.ts +3 -0
- package/src/rules/SK010-undeclared-release-unit.ts +2 -0
- package/src/rules/SK011-sibling-unit-ranges.ts +2 -0
- package/src/rules/SK012-config-barrel-import.ts +6 -2
- package/src/rules/SK013-one-way-in-policy.ts +24 -4
- package/src/rules/rule.ts +22 -1
- package/src/rules/run-fix.ts +5 -3
- package/src/scaffold/files/verify-skill.md +1 -1
- package/src/ts/config.ts +66 -6
- package/src/ts/cycles.ts +7 -92
- package/src/ts/source-graph.ts +158 -0
- package/src/ts/tsconfig-projects.ts +101 -0
- package/src/ts/typecheck-cli.ts +46 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,12 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.35.0
|
|
4
|
+
|
|
5
|
+
- **`shipkit pb collection` mints imports of a package that exists.** The pocketbase data layer and the Mantine shell are one package now, `rvlib-pb`: the generated `columns.tsx`, `grid.tsx` and `index.ts` import `Column`, `LiveGrid` and `defineCollectionUI` from `rvlib-pb/ui`, and `LiveList` from `rvlib-pb`. The two tiers are different subpaths on purpose, so a generated grid that took its type from the ui subpath would typecheck and would put `@mantine/core` in the tsconfig of anything that reads it. This generator ships inside the tarball and writes into repos it never sees, so a stale specifier here keeps producing broken scaffolds long after a rename lands: the suite now asserts every import string the generator emits, which nothing did before.
|
|
6
|
+
|
|
7
|
+
## 0.34.0
|
|
8
|
+
|
|
9
|
+
- **SK013's autofix was manufacturing the FAIL its own lint then raised.** It wired a PreToolUse hook at `node_modules/rvlib-shipkit/src/hooks/one-way-in-guard.ts` whether or not that path existed, so any repo that runs shipkit through the global shim without installing the package came out of a repair pass permanently red. A machine-clearable red that a machine just created is a design bug. The hook is still declared in every case, so it starts working the moment the dependency lands, with no second fix run: what changed is the verdict. A guard under `node_modules/` that is not there yet is an uninstalled dependency, so it WARNs and names `bun install`, with the deny list standing as the whole floor meanwhile. A guard path the repo chose and got wrong still FAILs, because that one never resolves on its own.
|
|
10
|
+
|
|
11
|
+
- **Three agents in ONE checkout is the normal case, and shipkit is now correct under it.** Everything a run WRITES to the worktree (the fix pass, SK005's `bun update --latest`, hooks arming, the asks archive) takes a blocking lease named for that concern, under the gitignored `tmp/`. The loser waits, because a skipped repair never happens; a holder that is gone or wedged is stolen; a live one is refused by NAME with the lock path and the flag that skips the phase, capped at 60s so nothing hangs. Measured on five real gates racing one repository: with the lease, five of five read-modify-writes survive; without it, four of five are lost and nothing on screen says so. Both lock primitives were raced before they were trusted, and both first versions failed: `writeFileSync(..., 'wx')` publishes a NAME before a body, so a reader in that window steals a live holder's lease (6 of 20 trials overlapped), and a `read, rmSync, grab` steal lets every racer win it (18 of 40 overlapped, peak 3 inside the phase at once). The lock is `linkSync`d into place from a complete body, and a steal is a single `renameSync` that exactly one racer can perform.
|
|
12
|
+
- **The check cache is one immutable file per check, version and cache key**, under `<cacheDir>/<suite>/`, replacing one aggregate json read at the top of a run and rewritten at the bottom, unlocked. Three reproduced failures die with it: two overlapping runs where the second's write drops every entry the first recorded, a torn read that a bare catch turned into an empty cache, and a prune step that deleted "entries for checks I do not know about", which is exactly what a run narrowed by `--only` is. Nothing merges, nothing prunes, no lock is taken, and no check touches a neighbour's entry. Superseded key files are swept by AGE after 30 days, which needs no knowledge of the current check set.
|
|
13
|
+
- **Every task run owns `tmp/shipkit-ci/run-<pid>-<started>-<seq>/`.** The runner used to wipe the shared log dir at the top of every run, so a second gate deleted the logs the first was still streaming into and its FAIL line pointed at a path that no longer existed. Old dirs, and the per run release survey files a killed run leaves behind, are swept only when the pid is gone and the grace window has passed.
|
|
14
|
+
- **`shipkit bump` and `shipkit release` commit with a PATHSPEC.** A bare `git commit` takes the whole index, and agents in one checkout share one index, so a neighbour who staged a file a second earlier got it committed under "release 0.4.0" and tagged. Bump also asks `git worktree list` before merging and refuses by name when the release branch is checked out in another worktree, instead of dying on a raw git fatal or moving another agent's HEAD.
|
|
15
|
+
- **`core.hooksPath` retries on lock contention.** Two worktrees share one `.git/config`, so two runs arming at once made one of them report "hooks will not fire" about a repo whose hooks were fine.
|
|
16
|
+
- **Under CI, a fix pass that never ran now FAILs.** In verify mode the fix pass IS the check, so reporting "not run" and exiting 0 passed a commit nobody verified.
|
|
17
|
+
- **BREAKING, one export**: `checkAsks(dir, print)` is now `checkAsks(root, dir, print)` and returns a Promise, because the archive takes the repo's lease. Two concurrent runs used to spawn every probe twice and then throw an uncaught ENOENT when the loser renamed a file the winner had already moved.
|
|
18
|
+
- Every rule fixer writes through `atomicWrite`, so a killed run cannot leave a half written `package.json`, `.gitignore`, `CLAUDE.md` or `.claude/settings.json`. SK004's managed block gains `.*.tmp-*` in the same change, because that is the debris such a kill leaves anywhere in the tree.
|
|
19
|
+
- `run-fix.ts`'s unchanged-fix line carries a wire prefix. `shipkit ci` promises every line it emits is parseable, and that one was bare.
|
|
20
|
+
|
|
21
|
+
- **The typecheck slot could report green over a file that does not compile.** A solution-style `tsconfig.json` (`"files": []` plus `references`, which is the standard vite shape) makes `tsc --noEmit` compile ZERO files and exit 0, so the built-in typecheck floor printed `ok: typecheck (0.1s)` with a type error sitting in `src/`. Every other failure in this tool is loud; this one was a green indistinguishable from a measured one. `ts/tsconfig-projects.ts` now resolves the set of projects a typecheck must visit: it walks `references`, and it keeps a referencing config in that set whenever the config compiles inputs of its OWN, because `references` does not suppress them. Only `"files": []` with no `include`, or an empty `include`, makes a config compile nothing; a bare `{ references: [...] }` still compiles the whole tree, since tsc defaults `include` to `**/*`. A reference may name a directory, cycles terminate, and a config reaching nothing that compiles is reported as VACUOUS. The floor spawns `ts/typecheck-cli.ts`, which resolves at RUN time so a tsconfig edited after resolution still counts, runs every project instead of stopping at the first red, and on success NAMES the projects it checked. A vacuous config or a dangling reference registers that runner too and exits non-zero, rather than leaving the slot unfilled: an unfilled slot is a skip, the gate still exits 0, and a repo with no typecheck coverage would read exactly like one that passed. `release/unit-gate.ts` ran the same vacuous command as a PUBLISH gate and got the same treatment.
|
|
22
|
+
- **`parseJsonc` is a quote-aware scanner instead of two regexes**, because tsconfig is the one dialect where the regex version cannot work: `"src/**/*"` is a `/*` carrying its own `*/`, so a block-comment regex eats an arbitrary span of a valid file. Measured over the real tsconfigs on this machine, the old version THREW on 8 of them and silently rewrote `"src/**/*.ts"` to `"src*.ts"` in 8 more. It parses 80 of 80 now. This matters more than it did yesterday: the same parser gates typecheck and publish.
|
|
23
|
+
- **`bun run test:raw` takes a FILE filter, not a name filter.** Measured: bun's positional argument matches the test file path, and a single test by name is `bun run test:raw -- -t "<name>"`. The wrong form was in 0.33.0's own notes, in two verify-skill templates, on `build/shipkit.md`, and in the refusal message an agent reads at the exact moment it is blocked.
|
|
24
|
+
|
|
3
25
|
## 0.33.0
|
|
4
26
|
|
|
5
27
|
- **ONE WAY IN, enforced. A wired repo's `test`, `lint` and `typecheck` scripts route through shipkit, and the harness refuses the raw runner.** Measured in a real wired repo before this: `"lint": "eslint ."`, `"test": "bun test"`, no ci script, an empty `.claude/`. An agent doing the obvious thing ran the tool, shipkit never heard the call, and the cache it was building was worthless. **This changes the shape of every wired repo's package.json**, and 0.32.0 already shipped the first half of it without saying so, which is the gap this note closes. Rule SK001 (blocking, autofixable) now FAILs a governed script that runs the tool directly; the fix slides the body to `<id>:raw` and makes the name `shipkit ci --only <id>`. `<id>:raw` is what the gate itself runs, and what you use to iterate on one suite. Those THREE names only: `imports:check` and `cycles` are shipkit's own vocabulary, and `fmt` is the slot the fix pass invokes by script, so routing it would make the fixer call itself. `resolveFloor` moved to `core/floor.ts` and refuses to run a routed script, which spawns the gate from inside the gate.
|
|
6
28
|
- **New rule SK013 (blocking, autofixable) writes the harness half**, because a package.json cannot reach it: `bun test` is a bun builtin and no script can intercept it. A tracked `.claude/settings.json` gets `permissions.deny` for every raw runner plus a PreToolUse guard, both rendered from ONE table. The deny is the floor, unbypassable in either direction; the guard is the messenger, because a bare deny tells the model only that it was denied and it then goes hunting for another runner. Every refusal names the shipkit command, the narrow way in, and the exact line that lifts it. The guard reads the deny list out of the settings FILE, so deleting one `Bash(...)` line lifts both mechanisms at once. It also catches what a prefix rule cannot (`CI=1 bun test`, `./node_modules/.bin/tsc`) and segments at quote depth zero, so a commit message quoting a runner is data. SK013 FAILs when the declared guard script does not resolve: a hook that errors refuses nothing.
|
|
7
29
|
- **`shipkit init` repairs an existing repo, not just a fresh one**, calling the same fixers `shipkit check --fix` runs. That is the half that matters, because the repos that need this most already exist. `shipkit start` inherits it.
|
|
8
30
|
- **A `--only` run repairs the mechanical slots ALONE.** The structural rule fixers rewrite package.json and repo config, and arming rewrites `hooks/`. That is the right price for the full gate and the wrong one for `bun run test`, which now routes here: the reflex verb must not leave files in an agent's diff that it never wrote, and under CI a formatting rewrite came back as a test failure.
|
|
9
|
-
- **Escaping it**: delete the `Bash(<runner>*)` line from `.claude/settings.json`; or `repo.config.ts oneWayIn: { exempt: ['test'] }`, which drops the script requirement and the shell rules together (an unknown name throws at config load); a slot turned off with `defaults: { lint: false }` is exempt already. `bun run test:raw <
|
|
31
|
+
- **Escaping it**: delete the `Bash(<runner>*)` line from `.claude/settings.json`; or `repo.config.ts oneWayIn: { exempt: ['test'] }`, which drops the script requirement and the shell rules together (an unknown name throws at config load); a slot turned off with `defaults: { lint: false }` is exempt already. `bun run test:raw <test file>` stays open for iterating on one suite, and one test by name is `bun run test:raw -- -t "<name>"`. A repo with sub-packages gives each one its own `test:raw`, or the escape does not reach where the work is. The formatter is deliberately not denied at all.
|
|
10
32
|
- `--allow-dirty` now says WHY it is safe in one place and not the other: `npm publish` packs the working tree, not the commit, so dirt in other paths cannot reach the tarball and dirt inside the unit ships a half-written file under a tag that describes something else.
|
|
11
33
|
|
|
12
34
|
## 0.32.0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rvlib-shipkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
4
4
|
"description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/asks/asks-queue.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// Stdout contract (FROZEN — consumers parse the prefixes): `closed +
|
|
7
7
|
// archived <file>` / `archived <file>` / `still <status> <file>` / `N closed`.
|
|
8
8
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'fs'
|
|
9
|
+
import { atomicWrite, mutateLease, withMutateLease } from '../core/locked-write.ts'
|
|
9
10
|
import { join } from 'path'
|
|
10
11
|
|
|
11
12
|
export type Ask = { file: string; status: string; kind: string; created: string; blocks: string; check: string | null; title: string }
|
|
@@ -53,7 +54,19 @@ export function doneAsks(dir: string): Ask[] {
|
|
|
53
54
|
return readdirSync(done).filter((f) => f.endsWith('.md')).sort().reverse().map((f) => parse(done, f))
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
|
|
57
|
+
/** probe every open ask and archive the ones that now pass. Under the repo's
|
|
58
|
+
* mutate lease: two runs racing here each spawn every probe (which reach the
|
|
59
|
+
* world), and then one of them renames a file the other already moved, which
|
|
60
|
+
* is an uncaught ENOENT rather than a message. The loser waits and finds
|
|
61
|
+
* nothing left to close, which is the correct answer. */
|
|
62
|
+
export async function checkAsks(root: string, dir: string, print: (line: string) => void = console.log): Promise<{ closed: number }> {
|
|
63
|
+
const leased = await withMutateLease(mutateLease(root, 'asks'), { label: 'asks archive', print, maxWaitMs: 45_000 }, () => checkAsksBody(dir, print))
|
|
64
|
+
if (leased.ok) return leased.value
|
|
65
|
+
print(`WARN asks check skipped — ${leased.warn}`)
|
|
66
|
+
return { closed: 0 }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function checkAsksBody(dir: string, print: (line: string) => void): { closed: number } {
|
|
57
70
|
const done = join(dir, 'done')
|
|
58
71
|
mkdirSync(done, { recursive: true })
|
|
59
72
|
let closed = 0
|
|
@@ -63,7 +76,7 @@ export function checkAsks(dir: string, print: (line: string) => void = console.l
|
|
|
63
76
|
const p = Bun.spawnSync(['bash', '-lc', a.check])
|
|
64
77
|
if (p.exitCode === 0) {
|
|
65
78
|
const md = readFileSync(join(dir, a.file), 'utf8').replace(/^status:.*$/m, `status: done\nclosed: ${new Date().toISOString().slice(0, 10)}`)
|
|
66
|
-
|
|
79
|
+
atomicWrite(join(dir, a.file), md)
|
|
67
80
|
renameSync(join(dir, a.file), join(done, a.file))
|
|
68
81
|
print(`closed + archived ${a.file}`)
|
|
69
82
|
closed++
|
package/src/cli/cmd_Asks.ts
CHANGED
|
@@ -41,7 +41,8 @@ export class AsksCheckCommand extends Command {
|
|
|
41
41
|
|
|
42
42
|
async execute(): Promise<number> {
|
|
43
43
|
try {
|
|
44
|
-
|
|
44
|
+
const root = process.cwd()
|
|
45
|
+
await checkAsks(root, await asksDir(root))
|
|
45
46
|
return 0
|
|
46
47
|
} catch (e) {
|
|
47
48
|
this.context.stderr.write(`FAIL: ${e instanceof Error ? e.message : String(e)}\n`)
|
package/src/cli/cmd_Ci.ts
CHANGED
|
@@ -16,6 +16,7 @@ export class CiCommand extends Command {
|
|
|
16
16
|
['leave repo config files alone', 'shipkit ci --no-fix-rules'],
|
|
17
17
|
['stop at the first failure', 'shipkit ci --bail'],
|
|
18
18
|
['one task + its dep closure', 'shipkit ci --only typecheck'],
|
|
19
|
+
['re-measure everything, replay nothing', 'shipkit ci --fresh'],
|
|
19
20
|
['include an opt-in task', 'shipkit ci --with up:itch'],
|
|
20
21
|
],
|
|
21
22
|
})
|
|
@@ -28,6 +29,7 @@ export class CiCommand extends Command {
|
|
|
28
29
|
skip = Option.Array('--skip', [], { description: 'skip a task group (repeatable)' })
|
|
29
30
|
withIds = Option.Array('--with', [], { description: 'include an opt-in task (repeatable)' })
|
|
30
31
|
only = Option.Array('--only', [], { description: 'run only this task + its dep closure (repeatable)' })
|
|
32
|
+
fresh = Option.Boolean('--fresh', false, { description: 'measure everything: no unit replays from the ledger, and what this run measures is stored fresh' })
|
|
31
33
|
full = Option.Boolean('--full', false, { description: `run the FULL test tier: every suite the repo declares, the slow ones it skips while you work included. What the release path always runs. Same as SHIPKIT_TEST_FULL=1` })
|
|
32
34
|
|
|
33
35
|
async execute(): Promise<number> {
|
|
@@ -45,6 +47,7 @@ export class CiCommand extends Command {
|
|
|
45
47
|
skipGroups: this.skip,
|
|
46
48
|
withIds: this.withIds,
|
|
47
49
|
onlyIds: this.only,
|
|
50
|
+
fresh: this.fresh,
|
|
48
51
|
...(this.full ? { tests: 'full' as const } : {}),
|
|
49
52
|
print: (l) => this.context.stdout.write(`${l}\n`),
|
|
50
53
|
})
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// shipkit keys — what every gate unit is keyed on, and what it is NOT.
|
|
2
|
+
//
|
|
3
|
+
// Nothing is cached yet. This command exists so the key layer is auditable by
|
|
4
|
+
// eye BEFORE anything replays off it: a cache whose keys nobody can read is a
|
|
5
|
+
// cache nobody can debug the day it serves a wrong answer. `--why <id>` prints
|
|
6
|
+
// the exact component list a key was hashed from, which is the only honest
|
|
7
|
+
// answer to "why did this key move".
|
|
8
|
+
import { createHash } from 'crypto'
|
|
9
|
+
import { Command, Option } from 'clipanion'
|
|
10
|
+
import { loadRepoConfig } from '../repo-config.ts'
|
|
11
|
+
import { envTier, FULL_TESTS_ENV, gateUnits, tieredTasks } from '../ledger/gate-units.ts'
|
|
12
|
+
import { KeyLayer } from '../ledger/git-key.ts'
|
|
13
|
+
|
|
14
|
+
export class KeysCommand extends Command {
|
|
15
|
+
static override paths = [['keys']]
|
|
16
|
+
|
|
17
|
+
static override usage = Command.Usage({
|
|
18
|
+
category: 'Health',
|
|
19
|
+
description: 'the cache key of every gate unit (task, rule, check), and the reason the undeclared ones have none. Reports, never gates',
|
|
20
|
+
examples: [
|
|
21
|
+
['every unit and its key', 'shipkit keys'],
|
|
22
|
+
['only the ones nothing can cache yet', 'shipkit keys --burn-down'],
|
|
23
|
+
['why this key is what it is', 'shipkit keys --why typecheck'],
|
|
24
|
+
['re-hash after the fact and report drift', 'shipkit keys --rescan'],
|
|
25
|
+
['the keys a --full gate asks for', 'shipkit keys --full'],
|
|
26
|
+
['machine-readable', 'shipkit keys --json'],
|
|
27
|
+
],
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
json = Option.Boolean('--json', false, { description: 'machine-readable { units: UnitKey[], burnDown, errors, scanMs }' })
|
|
31
|
+
burnDown = Option.Boolean('--burn-down', false, { description: 'list only the units nothing can cache yet, and why' })
|
|
32
|
+
why = Option.String('--why', { required: false, description: 'print every component that went into ONE unit\'s key' })
|
|
33
|
+
full = Option.Boolean('--full', false, { description: `render the keys a FULL-tier run asks for (same as ${FULL_TESTS_ENV}=1). The tier is in every task's key` })
|
|
34
|
+
rescan = Option.Boolean('--rescan', false, { description: 'run the post-run re-hash and name every input that moved since the scan' })
|
|
35
|
+
|
|
36
|
+
async execute(): Promise<number> {
|
|
37
|
+
const root = process.cwd()
|
|
38
|
+
const c = await loadRepoConfig(root)
|
|
39
|
+
// the TIER the gate would run, applied here too: `--full` rewrites
|
|
40
|
+
// every task's env, which is in the key, so a surface that renders keys
|
|
41
|
+
// without it renders a tier nobody asked for
|
|
42
|
+
const tier = this.full ? 'full' : envTier()
|
|
43
|
+
const decls = gateUnits(root, c, tieredTasks(c, root, tier))
|
|
44
|
+
const layer = KeyLayer.open(root, decls, { cliEntry: new URL('../cli.ts', import.meta.url).pathname })
|
|
45
|
+
const keys = layer.keys()
|
|
46
|
+
const out = (l: string): void => { this.context.stdout.write(`${l}\n`) }
|
|
47
|
+
|
|
48
|
+
if (this.json) {
|
|
49
|
+
const drift = this.rescan ? layer.rescan() : null
|
|
50
|
+
out(JSON.stringify({ tier, units: keys, burnDown: layer.registration.burnDown, errors: layer.registration.errors, layerMs: Math.round(layer.openMs), scanMs: Math.round(layer.scanMs), failure: layer.failure, drift }))
|
|
51
|
+
return layer.registration.errors.length > 0 || layer.failure != null ? 1 : 0
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (this.why != null) {
|
|
55
|
+
const k = keys.find((u) => u.id.toLowerCase() === this.why!.toLowerCase())
|
|
56
|
+
if (k == null) {
|
|
57
|
+
out(`FAIL: no gate unit '${this.why}' — run \`shipkit keys\` for the list`)
|
|
58
|
+
return 1
|
|
59
|
+
}
|
|
60
|
+
out(`${k.kind} ${k.id} — ${k.population}`)
|
|
61
|
+
if (k.key == null) {
|
|
62
|
+
out(` no key: ${k.reason ?? 'undeclared'}`)
|
|
63
|
+
return 0
|
|
64
|
+
}
|
|
65
|
+
out(` key ${k.key}`)
|
|
66
|
+
// the excluded block is 44 lines here and it buries the component
|
|
67
|
+
// that actually moved. It stays HASHED line by line, so this is a
|
|
68
|
+
// rendering choice and not a different key: the digest moves with
|
|
69
|
+
// the set, which is what answers "why did this move", and --json
|
|
70
|
+
// still carries every path.
|
|
71
|
+
const excluded = k.components.filter((c) => c.startsWith('excluded='))
|
|
72
|
+
for (const comp of k.components) if (!comp.startsWith('excluded=')) out(` ${comp}`)
|
|
73
|
+
if (excluded.length > 0) {
|
|
74
|
+
const digest = createHash('sha256').update(excluded.join('\n')).digest('hex').slice(0, 8)
|
|
75
|
+
out(` excluded ${excluded.length} path(s), digest ${digest} — not in the key's content, only in its meaning`)
|
|
76
|
+
for (const c of excluded.slice(0, 3)) out(` ${c.slice('excluded='.length)}`)
|
|
77
|
+
if (excluded.length > 3) out(` … ${excluded.length - 3} more (shipkit keys --json)`)
|
|
78
|
+
}
|
|
79
|
+
return 0
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (const e of layer.registration.errors) out(`FAIL: ${e}`)
|
|
83
|
+
if (layer.failure != null) {
|
|
84
|
+
out(`FAIL: git scan failed, no unit may be keyed — ${layer.failure}`)
|
|
85
|
+
out(' fix: nothing is cached, so the gate is unaffected. A stale scratch index clears with: rm .git/shipkit/scan.idx')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// the burn-down is what nobody has LOOKED at. A unit that declared
|
|
89
|
+
// `never` with a reason has been looked at, and listing it here would
|
|
90
|
+
// put four permanent rows on a list whose whole job is to shrink
|
|
91
|
+
const decided = new Set(layer.decls.filter((d) => d.never != null && d.never !== '').map((d) => d.id))
|
|
92
|
+
const shown = this.burnDown ? keys.filter((k) => k.population === 'none' && !decided.has(k.id)) : keys
|
|
93
|
+
for (const k of shown) out(`${k.population.padEnd(7)} ${k.kind.padEnd(5)} ${k.id.padEnd(28)} ${k.key ?? `— ${k.reason ?? ''}`}`)
|
|
94
|
+
|
|
95
|
+
const content = keys.filter((k) => k.population === 'content').length
|
|
96
|
+
const world = keys.filter((k) => k.population === 'world').length
|
|
97
|
+
const none = keys.filter((k) => k.population === 'none').length
|
|
98
|
+
const todo = layer.registration.burnDown.length
|
|
99
|
+
out(`\n${keys.length} units — ${content} content, ${world} world, ${none} uncacheable (${none - todo} decided, ${todo} still undeclared) · ${tier} test tier · key layer ${Math.round(layer.openMs)}ms, of which ${Math.round(layer.scanMs)}ms of git`)
|
|
100
|
+
if (todo > 0 && !this.burnDown) out(` the burn-down: shipkit keys --burn-down`)
|
|
101
|
+
|
|
102
|
+
if (this.rescan) {
|
|
103
|
+
const d = layer.rescan()
|
|
104
|
+
if (d.error != null) out(`FAIL: re-hash failed — ${d.error}`)
|
|
105
|
+
else if (d.drifted.length === 0) out(`ok: post-run re-hash clean, nothing moved (${Math.round(d.ms)}ms)`)
|
|
106
|
+
else {
|
|
107
|
+
out(`WARN ${d.drifted.length} input(s) moved since the scan — a run would discard its results rather than store them:`)
|
|
108
|
+
for (const p of d.drifted) out(` ${p}`)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return layer.registration.errors.length > 0 || layer.failure != null ? 1 : 0
|
|
112
|
+
}
|
|
113
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { CiCommand } from './cli/cmd_Ci.ts'
|
|
|
13
13
|
import { ContractsCommand } from './cli/cmd_Contracts.ts'
|
|
14
14
|
import { HooksCommitMsgCommand, HooksInstallCommand, HooksListCommand, HooksPreCommitCommand } from './cli/cmd_Hooks.ts'
|
|
15
15
|
import { InitCommand } from './cli/cmd_Init.ts'
|
|
16
|
+
import { KeysCommand } from './cli/cmd_Keys.ts'
|
|
16
17
|
import { MvCommand } from './cli/cmd_Mv.ts'
|
|
17
18
|
import { NeedsCommand } from './cli/cmd_Needs.ts'
|
|
18
19
|
import { PbCollectionCommand } from './cli/cmd_Pb.ts'
|
|
@@ -70,6 +71,7 @@ export function buildCli(): Cli {
|
|
|
70
71
|
cli.register(PbCollectionCommand)
|
|
71
72
|
cli.register(ReflectCommand)
|
|
72
73
|
cli.register(RulesCommand)
|
|
74
|
+
cli.register(KeysCommand)
|
|
73
75
|
cli.register(CommandsCommand)
|
|
74
76
|
|
|
75
77
|
return cli
|
package/src/core/check-suite.ts
CHANGED
|
@@ -2,22 +2,27 @@
|
|
|
2
2
|
// renders the v0 wire contract (wire.ts) with exit code.
|
|
3
3
|
//
|
|
4
4
|
// The TTL cache is the API-spam guard: a passing check with `remainValidFor`
|
|
5
|
-
// replays
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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.
|
|
8
15
|
//
|
|
9
16
|
// Secret VALUES never enter this module's state beyond the injected
|
|
10
17
|
// getSecret call + a truncated hash for cache-busting.
|
|
11
|
-
import { existsSync
|
|
12
|
-
import { join } from 'path'
|
|
18
|
+
import { existsSync } from 'fs'
|
|
13
19
|
import { CheckCtx, RateLimited, ToolUnresolved } from './check-ctx.ts'
|
|
20
|
+
import { dropStored, entryPath, FRESH_ENV, readStored, sweepStore, writeStored } from '../ledger/store.ts'
|
|
14
21
|
import { fmtStamp, formatLine, parseDuration, type Line } from './wire.ts'
|
|
15
22
|
|
|
16
23
|
type Logic = (t: CheckCtx) => unknown
|
|
17
24
|
type Prep = { skip?: string; cacheExtra?: string }
|
|
18
25
|
type Runner = { name: string; ttlMs: number; version: number; prep?: () => Prep; logic: Logic; blocking: boolean }
|
|
19
|
-
type CacheEntry = { version: number; key: string; at: number; lines: Line[] }
|
|
20
|
-
type CacheFile = Record<string, CacheEntry>
|
|
21
26
|
|
|
22
27
|
export type GetSecret = (name: string, field: 'get' | 'account') => string | null
|
|
23
28
|
|
|
@@ -28,15 +33,19 @@ export type SecretSpec = {
|
|
|
28
33
|
skipHint: string
|
|
29
34
|
}
|
|
30
35
|
|
|
31
|
-
export type CheckOutcome = { lines: Line[]; status: 'pass' | 'fail' | 'warn' | 'skip'; cached: boolean }
|
|
36
|
+
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 }
|
|
32
37
|
|
|
33
38
|
export type CheckSuiteOpts = {
|
|
34
39
|
name: string
|
|
35
|
-
|
|
40
|
+
/** the ONE store root (ledger/store.ts storeRoot). Injected rather than
|
|
41
|
+
* resolved here so a test can point a suite at a scratch dir. */
|
|
42
|
+
storeDir: string
|
|
36
43
|
now?: () => number
|
|
37
44
|
/** secret backend — consumers inject their own store; default = nothing stored, every secret check skips */
|
|
38
45
|
getSecret?: GetSecret
|
|
39
|
-
/** env var that busts
|
|
46
|
+
/** TEST SEAM: the env var that busts this suite's replays. Production is
|
|
47
|
+
* always the one house-wide flag (ledger/store.ts FRESH_ENV); a test
|
|
48
|
+
* overrides it so busting one suite cannot poison a neighbour's run. */
|
|
40
49
|
freshEnv?: string
|
|
41
50
|
/** per-check completion tap (the TUI streams results in) — cached replays
|
|
42
51
|
* report cached:true, prep-skips (unstored secret) report status 'skip' */
|
|
@@ -47,7 +56,7 @@ export class CheckSuite {
|
|
|
47
56
|
readonly name: string
|
|
48
57
|
private runners: Runner[] = []
|
|
49
58
|
private toolPaths = new Map<string, string>()
|
|
50
|
-
private
|
|
59
|
+
private storeDir: string
|
|
51
60
|
private now: () => number
|
|
52
61
|
private getSecret: GetSecret
|
|
53
62
|
private freshEnv: string
|
|
@@ -55,10 +64,10 @@ export class CheckSuite {
|
|
|
55
64
|
|
|
56
65
|
constructor(opts: CheckSuiteOpts) {
|
|
57
66
|
this.name = opts.name
|
|
58
|
-
this.
|
|
67
|
+
this.storeDir = opts.storeDir
|
|
59
68
|
this.now = opts.now ?? Date.now
|
|
60
69
|
this.getSecret = opts.getSecret ?? (() => null)
|
|
61
|
-
this.freshEnv = opts.freshEnv ??
|
|
70
|
+
this.freshEnv = opts.freshEnv ?? FRESH_ENV
|
|
62
71
|
this.onResult = opts.onResult
|
|
63
72
|
}
|
|
64
73
|
|
|
@@ -128,21 +137,29 @@ export class CheckSuite {
|
|
|
128
137
|
})
|
|
129
138
|
}
|
|
130
139
|
|
|
140
|
+
/** the key a world answer is remembered under: the check's own version
|
|
141
|
+
* plus whatever the prep declared (a secret's value hash, a rule's
|
|
142
|
+
* cacheKey). A version bump and a rotation both move it, so neither can
|
|
143
|
+
* replay across the change. */
|
|
144
|
+
private cacheKey(r: Runner, extra: string): string {
|
|
145
|
+
return `v${r.version}\0${extra}`
|
|
146
|
+
}
|
|
147
|
+
|
|
131
148
|
// test seam — run() prints + exits; execute() returns the structured result
|
|
132
149
|
/** ONE check: cache replay, run, classify, and what it wants remembered.
|
|
133
150
|
* Pure with respect to its neighbours, which is what lets execute() run
|
|
134
151
|
* them together. */
|
|
135
|
-
private async runOne(r: Runner
|
|
152
|
+
private async runOne(r: Runner): Promise<{ lines: Line[]; skip: string | null; ran: boolean; failed: boolean }> {
|
|
136
153
|
const prep = r.prep?.() ?? {}
|
|
137
154
|
if (prep.skip != null) {
|
|
138
|
-
this.onResult?.(r.name, { lines: [{ kind: 'skip', text: prep.skip }], status: 'skip', cached: false })
|
|
139
|
-
return { lines: [], skip: prep.skip, ran: false, failed: false
|
|
155
|
+
this.onResult?.(r.name, { lines: [{ kind: 'skip', text: prep.skip }], status: 'skip', cached: false, ms: 0 })
|
|
156
|
+
return { lines: [], skip: prep.skip, ran: false, failed: false }
|
|
140
157
|
}
|
|
141
|
-
const key = prep.cacheExtra ?? ''
|
|
142
|
-
const
|
|
158
|
+
const key = this.cacheKey(r, prep.cacheExtra ?? '')
|
|
159
|
+
const path = entryPath(this.storeDir, this.name, r.name, key)
|
|
160
|
+
const entry = readStored<Line[]>(path, key)
|
|
143
161
|
const fresh = r.ttlMs > 0 && process.env[this.freshEnv] == null
|
|
144
|
-
&& entry != null && entry.
|
|
145
|
-
&& this.now() - entry.at < r.ttlMs && this.now() >= entry.at
|
|
162
|
+
&& entry != null && this.now() - entry.at < r.ttlMs && this.now() >= entry.at
|
|
146
163
|
if (fresh) {
|
|
147
164
|
// the stamp was baked in when the check RAN (below): a replay
|
|
148
165
|
// prints the stored lines VERBATIM, so the live run and every
|
|
@@ -152,11 +169,12 @@ export class CheckSuite {
|
|
|
152
169
|
// alternation, so neither side annotates
|
|
153
170
|
// a replayed WORLD negative keeps its severity: it is still the
|
|
154
171
|
// answer, and demoting it to a pass would hide a real finding
|
|
155
|
-
const replayedWarn = entry.
|
|
156
|
-
this.onResult?.(r.name, { lines: entry.
|
|
157
|
-
return { lines: entry.
|
|
172
|
+
const replayedWarn = entry.payload.some((l) => l.kind === 'warn' || l.kind === 'fail')
|
|
173
|
+
this.onResult?.(r.name, { lines: entry.payload, status: replayedWarn ? 'warn' : 'pass', cached: true, ms: 0 })
|
|
174
|
+
return { lines: entry.payload, skip: null, ran: true, failed: false }
|
|
158
175
|
}
|
|
159
176
|
const t = new CheckCtx(this.toolPaths)
|
|
177
|
+
const started = performance.now()
|
|
160
178
|
try {
|
|
161
179
|
await r.logic(t)
|
|
162
180
|
} catch (e) {
|
|
@@ -193,24 +211,23 @@ export class CheckSuite {
|
|
|
193
211
|
const lines = cacheable
|
|
194
212
|
? t.lines.map((l, i): Line => (i === 0 ? { ...l, text: `${l.text} (${clean ? 'cached' : 'measured'} at ${fmtStamp(this.now())} until ${fmtStamp(this.now() + r.ttlMs)})` } : l))
|
|
195
213
|
: t.lines
|
|
196
|
-
this.onResult?.(r.name, { lines, status: t.failed ? 'fail' : t.warned ? 'warn' : 'pass', cached: false })
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
failed: t.failed,
|
|
202
|
-
store: cacheable && r.ttlMs > 0 ? { version: r.version, key, at: this.now(), lines } : null,
|
|
214
|
+
this.onResult?.(r.name, { lines, status: t.failed ? 'fail' : t.warned ? 'warn' : 'pass', cached: false, ms: performance.now() - started })
|
|
215
|
+
// this check writes its OWN file, and only its own: nothing here reads
|
|
216
|
+
// or rewrites a neighbour's entry, so a racing runner can lose nothing
|
|
217
|
+
if (r.ttlMs > 0) {
|
|
218
|
+
if (cacheable) writeStored(path, key, lines, this.now())
|
|
203
219
|
// an unmeasured run evicts whatever was stored: a later run must
|
|
204
|
-
// re-probe rather than replay an answer recorded before the outage
|
|
205
|
-
|
|
220
|
+
// re-probe rather than replay an answer recorded before the outage.
|
|
221
|
+
// A racing runner may have written a good answer into that same
|
|
222
|
+
// file a moment ago and lose it here, which costs one re-probe and
|
|
223
|
+
// can never produce a stale green, so it is the safe direction.
|
|
224
|
+
else if (entry != null) dropStored(path)
|
|
206
225
|
}
|
|
226
|
+
return { lines, skip: null, ran: true, failed: t.failed }
|
|
207
227
|
}
|
|
208
228
|
|
|
209
229
|
async execute(): Promise<{ lines: Line[]; exitCode: number }> {
|
|
210
|
-
|
|
211
|
-
let cache: CacheFile = {}
|
|
212
|
-
try { cache = JSON.parse(readFileSync(cachePath, 'utf8')) } catch {}
|
|
213
|
-
let cacheDirty = false
|
|
230
|
+
sweepStore(this.storeDir, this.name, this.now())
|
|
214
231
|
|
|
215
232
|
// CONCURRENT, bounded. Checks are independent by contract: each answers
|
|
216
233
|
// one question about the world or the repo and shares nothing with its
|
|
@@ -227,7 +244,7 @@ export class CheckSuite {
|
|
|
227
244
|
for (;;) {
|
|
228
245
|
const i = next++
|
|
229
246
|
if (i >= this.runners.length) return
|
|
230
|
-
results[i] = await this.runOne(this.runners[i]
|
|
247
|
+
results[i] = await this.runOne(this.runners[i]!)
|
|
231
248
|
}
|
|
232
249
|
}
|
|
233
250
|
await Promise.all(Array.from({ length: Math.min(cap, this.runners.length) }, () => worker()))
|
|
@@ -238,13 +255,10 @@ export class CheckSuite {
|
|
|
238
255
|
let anyFail = false
|
|
239
256
|
for (let i = 0; i < this.runners.length; i++) {
|
|
240
257
|
const r = results[i]!
|
|
241
|
-
const name = this.runners[i]!.name
|
|
242
258
|
out.push(...r.lines)
|
|
243
259
|
if (r.skip != null) skipped.push(r.skip)
|
|
244
260
|
if (r.ran) ran++
|
|
245
261
|
if (r.failed) anyFail = true
|
|
246
|
-
if (r.store != null) { cache[name] = r.store; cacheDirty = true }
|
|
247
|
-
else if (r.evict) { delete cache[name]; cacheDirty = true }
|
|
248
262
|
}
|
|
249
263
|
|
|
250
264
|
// contract: `skip:` lines ONLY when no check ran at all (whole probe is
|
|
@@ -253,13 +267,6 @@ export class CheckSuite {
|
|
|
253
267
|
if (ran === 0) out.push(...skipped.map((s): Line => ({ kind: 'skip', text: `${s} (creds gate reports it)` })))
|
|
254
268
|
else out.push(...skipped.map((s): Line => ({ kind: 'note', text: `${s} — validation skipped (creds gate reports it)` })))
|
|
255
269
|
|
|
256
|
-
if (cacheDirty) {
|
|
257
|
-
// prune entries for renamed/removed checks — the file must not accrete
|
|
258
|
-
const known = new Set(this.runners.map((r) => r.name))
|
|
259
|
-
for (const k of Object.keys(cache)) if (!known.has(k)) delete cache[k]
|
|
260
|
-
mkdirSync(this.cacheDir, { recursive: true })
|
|
261
|
-
writeFileSync(cachePath, JSON.stringify(cache))
|
|
262
|
-
}
|
|
263
270
|
return { lines: out, exitCode: anyFail ? 1 : 0 }
|
|
264
271
|
}
|
|
265
272
|
|
|
@@ -17,17 +17,34 @@ export type DiscoveredCheck = {
|
|
|
17
17
|
path: string
|
|
18
18
|
/** display + cache name — file base, disambiguated by relative path on collision */
|
|
19
19
|
name: string
|
|
20
|
-
/** file runs its own CheckSuite (own TTL cache) — the orchestrator must not double-cache it */
|
|
21
|
-
selfCached: boolean
|
|
22
20
|
/** `export const blocking = true | false`, read statically. null = the file
|
|
23
21
|
* never said, which is an error: a check that silently does not block is
|
|
24
22
|
* the failure nobody sees. */
|
|
25
23
|
blocking: boolean | null
|
|
24
|
+
/** `export const inputs = ['a', 'b']`, read statically — the repo-relative
|
|
25
|
+
* paths this check reads. Declares the CONTENT population for the ledger
|
|
26
|
+
* key layer (ledger/git-key.ts). null = never cached, which is the safe
|
|
27
|
+
* default and the whole point of default deny. */
|
|
28
|
+
inputs: string[] | null
|
|
29
|
+
/** `export const ttl = '24h'`, read statically — declares the WORLD
|
|
30
|
+
* population: this check probes something no file on disk describes. */
|
|
31
|
+
ttl: string | null
|
|
32
|
+
/** `export const never = 'why'`, read statically — a DECIDED never
|
|
33
|
+
* cacheable, which takes the check off the burn-down list. */
|
|
34
|
+
never: string | null
|
|
26
35
|
}
|
|
27
36
|
|
|
28
37
|
// read without importing: checks run as subprocesses, and discovery must stay
|
|
29
38
|
// pure-fs. `export const blocking = true` (or false), nothing cleverer.
|
|
30
39
|
const BLOCKING_RE = /^\s*export\s+const\s+blocking\s*(?::\s*boolean\s*)?=\s*(true|false)\s*$/m
|
|
40
|
+
// `export const inputs = ['a', 'b']`, single or multi line. Anything cleverer
|
|
41
|
+
// (a computed list, a spread) is deliberately unreadable here and lands as no
|
|
42
|
+
// declaration, which means never cached: a scanner guessing at a program is
|
|
43
|
+
// how a key comes to describe files the unit does not read.
|
|
44
|
+
const INPUTS_RE = /^\s*export\s+const\s+inputs\s*(?::[^=]*)?=\s*\[([^\]]*)\]/m
|
|
45
|
+
const TTL_RE = /^\s*export\s+const\s+ttl\s*(?::\s*string\s*)?=\s*['"]([^'"]+)['"]/m
|
|
46
|
+
const NEVER_RE = /^\s*export\s+const\s+never\s*(?::\s*string\s*)?=\s*['"]([^'"]+)['"]/m
|
|
47
|
+
const STRING_ITEM_RE = /['"]([^'"]+)['"]/g
|
|
31
48
|
|
|
32
49
|
export function discoverChecks(root: string): DiscoveredCheck[] {
|
|
33
50
|
const files: string[] = []
|
|
@@ -48,16 +65,22 @@ export function discoverChecks(root: string): DiscoveredCheck[] {
|
|
|
48
65
|
return files.map((f) => {
|
|
49
66
|
const base = baseOf(f)
|
|
50
67
|
const name = (seen.get(base) ?? 0) > 1 ? relative(root, f).slice(0, -SUFFIX.length) : base
|
|
51
|
-
// a check built on CheckSuite/PageValidator replays from its OWN
|
|
52
|
-
// TTL cache — caching it again up here would double the staleness
|
|
53
|
-
let selfCached = false
|
|
54
68
|
let blocking: boolean | null = null
|
|
69
|
+
let inputs: string[] | null = null
|
|
70
|
+
let ttl: string | null = null
|
|
71
|
+
let never: string | null = null
|
|
55
72
|
try {
|
|
56
73
|
const src = readFileSync(f, 'utf8')
|
|
57
|
-
selfCached = /PageValidator|CheckSuite/.test(src)
|
|
58
74
|
const m = src.match(BLOCKING_RE)
|
|
59
75
|
blocking = m == null ? null : m[1] === 'true'
|
|
76
|
+
const im = src.match(INPUTS_RE)
|
|
77
|
+
if (im != null) {
|
|
78
|
+
const items = [...im[1]!.matchAll(STRING_ITEM_RE)].map((x) => x[1]!)
|
|
79
|
+
if (items.length > 0) inputs = items
|
|
80
|
+
}
|
|
81
|
+
ttl = src.match(TTL_RE)?.[1] ?? null
|
|
82
|
+
never = src.match(NEVER_RE)?.[1] ?? null
|
|
60
83
|
} catch {}
|
|
61
|
-
return { path: f, name,
|
|
84
|
+
return { path: f, name, blocking, inputs, ttl, never }
|
|
62
85
|
})
|
|
63
86
|
}
|
package/src/core/floor.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { existsSync, readFileSync } from 'fs'
|
|
|
9
9
|
import { join } from 'path'
|
|
10
10
|
import { fileURLToPath } from 'url'
|
|
11
11
|
import { rawName, routesToShipkit } from './canonical-scripts.ts'
|
|
12
|
+
import { resolveTypecheckProjects } from '../ts/tsconfig-projects.ts'
|
|
12
13
|
import type { TaskDef } from './task-runner.ts'
|
|
13
14
|
import type { RepoConfig } from '../repo-config.ts'
|
|
14
15
|
|
|
@@ -40,11 +41,29 @@ function builtinFloorTask(id: string, root: string): { task: TaskDef; source: Fl
|
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
43
|
if (id === 'typecheck') {
|
|
43
|
-
|
|
44
|
+
const tsconfig = join(root, 'tsconfig.json')
|
|
45
|
+
if (!existsSync(tsconfig)) return `no 'typecheck' script and no tsconfig.json`
|
|
44
46
|
const local = join(root, 'node_modules/.bin/tsc')
|
|
45
47
|
const tsc = existsSync(local) ? local : Bun.which('tsc')
|
|
46
48
|
if (tsc == null) return `no 'typecheck' script and tsc not found — add typescript or a typecheck script`
|
|
47
|
-
|
|
49
|
+
// `tsc --noEmit` over a solution-style config (`files: []` plus
|
|
50
|
+
// `references`) compiles ZERO files and exits 0, so the slot reports a
|
|
51
|
+
// green nobody measured. only a config that is its OWN single project
|
|
52
|
+
// keeps the plain command; everything else goes through the runner,
|
|
53
|
+
// which resolves and REPORTS what it checked.
|
|
54
|
+
//
|
|
55
|
+
// a config that compiles nothing, or references a path that is gone,
|
|
56
|
+
// registers the runner too rather than leaving the slot unfilled: an
|
|
57
|
+
// unfilled slot is a skip, `shipkit ci` still exits 0, and a repo with
|
|
58
|
+
// no typecheck coverage would look the same as a repo that passed.
|
|
59
|
+
let plain = false
|
|
60
|
+
try {
|
|
61
|
+
const projects = resolveTypecheckProjects(tsconfig)
|
|
62
|
+
plain = projects.projects.length === 1 && projects.projects[0] === tsconfig && projects.missing.length === 0
|
|
63
|
+
} catch { plain = false }
|
|
64
|
+
if (plain) return { task: { id, cmd: [tsc, '--noEmit'] }, source: 'built-in (tsc)' }
|
|
65
|
+
const cli = fileURLToPath(import.meta.resolve('rvlib-shipkit/src/ts/typecheck-cli.ts'))
|
|
66
|
+
return { task: { id, cmd: ['bun', cli, tsc, tsconfig] }, source: 'built-in (tsc)' }
|
|
48
67
|
}
|
|
49
68
|
return `no package.json '${id}' script`
|
|
50
69
|
}
|
|
@@ -65,18 +84,27 @@ export function resolveFloor(c: RepoConfig, root: string): FloorResolution {
|
|
|
65
84
|
for (const id of FLOOR_IDS) {
|
|
66
85
|
const override = c.defaults?.[id]
|
|
67
86
|
if (override === false) { disabled.push(id); continue }
|
|
68
|
-
|
|
87
|
+
// a FULL override brings its own body and replaces the slot. One that
|
|
88
|
+
// brings none DECORATES whatever fills it, which is how a repo declares
|
|
89
|
+
// what its `test` script reads without restating the command: the read
|
|
90
|
+
// set is repo knowledge, the command is not.
|
|
91
|
+
if (override != null && (override.cmd != null || override.logic != null)) {
|
|
92
|
+
tasks.push({ ...override, id: override.id ?? id } as TaskDef)
|
|
93
|
+
sources[id] = 'repo.config override'
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
69
96
|
// ONE WAY IN: `<id>` routes to `shipkit ci --only <id>`, so the TOOL
|
|
70
97
|
// body lives at `<id>:raw` and that is what the gate runs. Running the
|
|
71
98
|
// routed script here would spawn the gate from inside itself, forever
|
|
99
|
+
const decorate = (t: TaskDef): TaskDef => (override == null ? t : { ...t, ...override, id })
|
|
72
100
|
const raw = scripts[rawName(id)]
|
|
73
|
-
if (raw != null) { tasks.push({ id, cmd: ['bun', 'run', rawName(id)] }); sources[id] = 'package.json script'; continue }
|
|
74
|
-
if (scripts[id] != null && !routesToShipkit(scripts[id]!)) { tasks.push({ id, cmd: ['bun', 'run', id] }); sources[id] = 'package.json script'; continue }
|
|
101
|
+
if (raw != null) { tasks.push(decorate({ id, cmd: ['bun', 'run', rawName(id)] })); sources[id] = 'package.json script'; continue }
|
|
102
|
+
if (scripts[id] != null && !routesToShipkit(scripts[id]!)) { tasks.push(decorate({ id, cmd: ['bun', 'run', id] })); sources[id] = 'package.json script'; continue }
|
|
75
103
|
const b = builtinFloorTask(id, root)
|
|
76
104
|
// a routed script with nothing behind it measures NOTHING, and the
|
|
77
105
|
// reason must say that rather than `no '<id>' script`, which is false
|
|
78
106
|
if (typeof b === 'string') unfilled.push({ id, reason: scripts[id] != null ? `script '${id}' routes to shipkit and there is no '${rawName(id)}' body to run` : b })
|
|
79
|
-
else { tasks.push(b.task); sources[id] = b.source }
|
|
107
|
+
else { tasks.push(decorate(b.task)); sources[id] = b.source }
|
|
80
108
|
}
|
|
81
109
|
return { tasks, disabled, unfilled, sources }
|
|
82
110
|
}
|