rvlib-shipkit 0.31.0 → 0.33.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +3 -0
  3. package/package.json +4 -1
  4. package/src/cli/cmd_Bump.ts +2 -0
  5. package/src/cli/cmd_Ci.ts +3 -1
  6. package/src/cli/cmd_Pb.ts +27 -0
  7. package/src/cli/cmd_Release.ts +1 -1
  8. package/src/cli/cmd_Wiki.ts +2 -2
  9. package/src/cli.ts +49 -39
  10. package/src/collections/providers.ts +7 -2
  11. package/src/contracts/contracts-once.ts +43 -0
  12. package/src/core/canonical-scripts.ts +35 -0
  13. package/src/core/check-ctx.ts +13 -0
  14. package/src/core/check-suite.ts +121 -53
  15. package/src/core/floor.ts +82 -0
  16. package/src/core/motherhome.ts +14 -1
  17. package/src/core/one-way-in-policy.ts +183 -0
  18. package/src/core/run-check.ts +34 -0
  19. package/src/core/run-ci.ts +80 -4
  20. package/src/core/run-fixers.ts +6 -1
  21. package/src/hooks/one-way-in-guard.ts +39 -0
  22. package/src/init-repo.ts +19 -1
  23. package/src/ledger/toolchain-identity.ts +134 -0
  24. package/src/pb/collection-scaffold.ts +299 -0
  25. package/src/pb/pb-types.ts +233 -0
  26. package/src/pb/run-pb.ts +157 -0
  27. package/src/release/git-facts.ts +3 -2
  28. package/src/release/release-scope.ts +52 -0
  29. package/src/release/releases-share.ts +85 -0
  30. package/src/release/run-bump.ts +31 -8
  31. package/src/release/run-release.ts +8 -1
  32. package/src/release/run-releases.ts +20 -6
  33. package/src/repo-config.ts +22 -65
  34. package/src/rules/SK001-package-json-shape.ts +65 -1
  35. package/src/rules/SK002-contracts-fulfillness.ts +2 -2
  36. package/src/rules/SK004-gitignore-policy.ts +27 -3
  37. package/src/rules/SK012-config-barrel-import.ts +60 -0
  38. package/src/rules/SK013-one-way-in-policy.ts +136 -0
  39. package/src/rules/rule-registry.ts +4 -0
  40. package/src/rules/untrack-generated.ts +117 -0
  41. package/src/scaffold/files/claude-settings-local.json +0 -3
  42. package/src/scaffold/files/verify-skill.md +1 -1
  43. package/src/scaffold/project-scaffold.ts +1 -1
  44. package/src/serve/hub-port.ts +17 -0
  45. package/src/serve/run-serve.ts +2 -5
  46. package/src/wiki/run-wiki.ts +18 -8
@@ -0,0 +1,82 @@
1
+ // resolveFloor — which task fills each floor slot (lint, typecheck, test,
2
+ // imports:check, cycles) and, when nothing can, the reason it stays empty.
3
+ //
4
+ // Its own module rather than a section of repo-config so a RULE can ask what
5
+ // the gate would actually run: repo-config imports the rule registry, so a
6
+ // rule importing repo-config back is a cycle that dies at module init.
7
+ // RepoConfig arrives as a TYPE only, which is erased.
8
+ import { existsSync, readFileSync } from 'fs'
9
+ import { join } from 'path'
10
+ import { fileURLToPath } from 'url'
11
+ import { rawName, routesToShipkit } from './canonical-scripts.ts'
12
+ import type { TaskDef } from './task-runner.ts'
13
+ import type { RepoConfig } from '../repo-config.ts'
14
+
15
+ export const FLOOR_IDS = ['lint', 'typecheck', 'test', 'imports:check', 'cycles'] as const
16
+
17
+ export type FloorSource = 'package.json script' | 'repo.config override' | 'built-in (shipkit)' | 'built-in (tsc)'
18
+ export type FloorResolution = {
19
+ tasks: TaskDef[]
20
+ /** slots disabled by `defaults: { x: false }` — always printed */
21
+ disabled: string[]
22
+ /** slots nothing could fill — always printed, never silent */
23
+ unfilled: { id: string; reason: string }[]
24
+ /** where each registered slot came from (init prints this) */
25
+ sources: Record<string, FloorSource>
26
+ }
27
+
28
+ // zero-config floor (O6): with no script, imports:check/cycles resolve to
29
+ // shipkit's own rvlib-ts-tools dep, typecheck to the repo's tsc. Returns
30
+ // the task, or the reason the slot stays empty.
31
+ function builtinFloorTask(id: string, root: string): { task: TaskDef; source: FloorSource } | string {
32
+ if (id === 'imports:check' || id === 'cycles') {
33
+ let cli: string
34
+ // shipkit's OWN module, resolved from this file: no dependency to be
35
+ // out of date with, and no resolution that can fail in a consumer tree
36
+ cli = fileURLToPath(import.meta.resolve('rvlib-shipkit/src/ts/source-graph-cli.ts'))
37
+ return {
38
+ task: { id, cmd: id === 'cycles' ? ['bun', cli, 'cycles'] : ['bun', cli, 'imports', '--check'] },
39
+ source: 'built-in (shipkit)',
40
+ }
41
+ }
42
+ if (id === 'typecheck') {
43
+ if (!existsSync(join(root, 'tsconfig.json'))) return `no 'typecheck' script and no tsconfig.json`
44
+ const local = join(root, 'node_modules/.bin/tsc')
45
+ const tsc = existsSync(local) ? local : Bun.which('tsc')
46
+ if (tsc == null) return `no 'typecheck' script and tsc not found — add typescript or a typecheck script`
47
+ return { task: { id, cmd: [tsc, '--noEmit'] }, source: 'built-in (tsc)' }
48
+ }
49
+ return `no package.json '${id}' script`
50
+ }
51
+
52
+ export function resolveFloor(c: RepoConfig, root: string): FloorResolution {
53
+ const pkgPath = join(root, 'package.json')
54
+ let scripts: Record<string, string> = {}
55
+ if (existsSync(pkgPath)) {
56
+ // unparseable package.json must NOT silently empty the floor — the
57
+ // gate would run only `contracts` and report green
58
+ try { scripts = (JSON.parse(readFileSync(pkgPath, 'utf8')) as { scripts?: Record<string, string> }).scripts ?? {} }
59
+ catch (e) { throw new Error(`package.json unparseable — floor tasks cannot register: ${String(e).slice(0, 120)}`) }
60
+ }
61
+ const tasks: TaskDef[] = []
62
+ const disabled: string[] = []
63
+ const unfilled: { id: string; reason: string }[] = []
64
+ const sources: Record<string, FloorSource> = {}
65
+ for (const id of FLOOR_IDS) {
66
+ const override = c.defaults?.[id]
67
+ if (override === false) { disabled.push(id); continue }
68
+ if (override != null) { tasks.push(override); sources[id] = 'repo.config override'; continue }
69
+ // ONE WAY IN: `<id>` routes to `shipkit ci --only <id>`, so the TOOL
70
+ // body lives at `<id>:raw` and that is what the gate runs. Running the
71
+ // routed script here would spawn the gate from inside itself, forever
72
+ 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 }
75
+ const b = builtinFloorTask(id, root)
76
+ // a routed script with nothing behind it measures NOTHING, and the
77
+ // reason must say that rather than `no '<id>' script`, which is false
78
+ 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 }
80
+ }
81
+ return { tasks, disabled, unfilled, sources }
82
+ }
@@ -10,7 +10,7 @@
10
10
  // path no longer exists are pruned on every write. Churn control: a row
11
11
  // rewrites only when its DATA changes or its last-seen day is older than
12
12
  // 7 days. Never fails the calling command.
13
- import { existsSync, readFileSync } from 'fs'
13
+ import { existsSync, readFileSync, statSync } from 'fs'
14
14
  import { homedir } from 'os'
15
15
  import { join, resolve } from 'path'
16
16
  import type { RepoConfig } from '../repo-config.ts'
@@ -46,6 +46,13 @@ function consumptionMode(root: string): string {
46
46
  } catch { return '?' }
47
47
  }
48
48
 
49
+ /** a LINKED worktree, where `.git` is a file pointing at the main checkout
50
+ * rather than a directory. Cheap on purpose: this runs on every check pass in
51
+ * every repo, and spawning git to learn it would cost more than the write. */
52
+ function isLinkedWorktree(root: string): boolean {
53
+ try { return statSync(join(root, '.git')).isFile() } catch { return false }
54
+ }
55
+
49
56
  export type MotherhomeOutcome = { wrote: boolean; note?: string }
50
57
 
51
58
  export function reportToMotherhome(root: string, config: RepoConfig, today: string = new Date().toISOString().slice(0, 10)): MotherhomeOutcome {
@@ -57,6 +64,12 @@ export function reportToMotherhome(root: string, config: RepoConfig, today: stri
57
64
  if (!base.startsWith('/')) return { wrote: false }
58
65
  const page = join(base, 'build/shipkit/local-projects.md')
59
66
  if (!existsSync(page)) return { wrote: false }
67
+ // a gate run inside a linked worktree must not write the MAIN
68
+ // checkout's tracked page: the row it would add describes a temporary
69
+ // side checkout, and the write lands as unexplained dirt in a tree
70
+ // somebody else is working in. The worktree's own runs are covered
71
+ // when its branch merges.
72
+ if (isLinkedWorktree(root)) return { wrote: false, note: `motherhome: skipped (${config.name} is a linked worktree — its row belongs to the main checkout)` }
60
73
 
61
74
  const mode = consumptionMode(root)
62
75
  // source-consumed code is not the published release its version
@@ -0,0 +1,183 @@
1
+ // ONE WAY IN, the harness half (build/shipkit.md). The package.json half
2
+ // (rule SK001) routes `bun run test` into shipkit, but `bun test` is a bun
3
+ // BUILTIN: no script can intercept it, so a package.json alone leaves the
4
+ // reflex wide open. Claude Code permission rules can close it.
5
+ //
6
+ // ONE table, two renderings, because two hand-kept lists drift:
7
+ // - `permissions.deny` in .claude/settings.json — declarative, unbypassable
8
+ // (deny beats allow at every settings level, and bypassPermissions mode
9
+ // does not lift it), and it survives a missing bun.
10
+ // - a PreToolUse hook — the only thing that can put a REASON in front of the
11
+ // model. A bare deny says "Permission to use Bash with command bun test
12
+ // has been denied." and nothing else, so the agent goes looking for
13
+ // another way in, which is the failure this whole file exists to prevent.
14
+ // The deny is the floor and the hook is the messenger. When the hook cannot
15
+ // run, enforcement degrades to terse rather than to nothing.
16
+ //
17
+ // Subprocesses are NOT intercepted (verified): shipkit spawning oxlint or
18
+ // `bun run test:raw` from inside a permitted `shipkit ci` is invisible to
19
+ // these rules, which is exactly what makes the gate itself still work.
20
+
21
+ export type RawRunner = {
22
+ /** the command head as an agent types it — the deny rule is this + `*` */
23
+ head: string
24
+ /** the floor slot it answers, so ONE exemption governs the script name and
25
+ * the shell command together: a repo that keeps `test` raw is not then
26
+ * refused `bun test` */
27
+ slot: 'test' | 'lint' | 'typecheck'
28
+ /** the shipkit command that answers the same question, cache and all */
29
+ use: string
30
+ /** the narrower way in, when there is one: iterating on a single repro
31
+ * test while you fix a bug is not a verification and stays normal. A
32
+ * FILTER, not a path: `bun run` quotes what it forwards, and bun reads a
33
+ * quoted path as a name filter that then matches nothing */
34
+ iterate?: string
35
+ }
36
+
37
+ // the house stack, plus the foreign runners a wired repo is actually found
38
+ // carrying. Kept tight on purpose: a deny an agent hits for no good reason
39
+ // teaches it to route around the whole file.
40
+ export const RAW_RUNNERS: readonly RawRunner[] = [
41
+ { head: 'bun test', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <name filter>' },
42
+ { head: 'bunx vitest', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <name filter>' },
43
+ { head: 'npx vitest', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <name filter>' },
44
+ { head: 'vitest', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <name filter>' },
45
+ { head: 'npx jest', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <name filter>' },
46
+ { head: 'jest', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <name filter>' },
47
+ { head: 'bunx tsc', slot: 'typecheck', use: 'shipkit ci --only typecheck' },
48
+ { head: 'npx tsc', slot: 'typecheck', use: 'shipkit ci --only typecheck' },
49
+ { head: 'tsc', slot: 'typecheck', use: 'shipkit ci --only typecheck' },
50
+ { head: 'bunx oxlint', slot: 'lint', use: 'shipkit ci --only lint' },
51
+ { head: 'npx oxlint', slot: 'lint', use: 'shipkit ci --only lint' },
52
+ { head: 'oxlint', slot: 'lint', use: 'shipkit ci --only lint' },
53
+ { head: 'npx eslint', slot: 'lint', use: 'shipkit ci --only lint' },
54
+ { head: 'eslint', slot: 'lint', use: 'shipkit ci --only lint' },
55
+ ]
56
+
57
+ // the FORMATTER is deliberately absent, and that is not an oversight. It
58
+ // answers no cached question and produces no green anyone could mistake for a
59
+ // verification, shipkit's own fix pass runs it BY SCRIPT (`bun run fmt`), and
60
+ // denying the bare binary while that stays open would be theatre. `shipkit ci`
61
+ // formats before it gates, which is the only reason the reflex matters at all.
62
+
63
+ /** the `permissions.deny` entries, in the syntax Claude Code matches:
64
+ * `Bash(<head>*)` is a prefix match, and a compound command is checked one
65
+ * segment at a time, so `cd x && bun test` is caught too */
66
+ export const denyRules = (exemptSlots: Iterable<string> = []): string[] => {
67
+ const off = new Set(exemptSlots)
68
+ return RAW_RUNNERS.filter((r) => !off.has(r.slot)).map((r) => `Bash(${r.head}*)`)
69
+ }
70
+
71
+ /** what a refusal says. Never just the ban: a refusal with no replacement
72
+ * sends the agent hunting for another runner. `segment` is the part of the
73
+ * command that offended, shown only when it says more than the head already
74
+ * does: a long compound refused on `CI=1 bun test --timeout 5` otherwise
75
+ * leaves the reader guessing which of its clauses was the problem */
76
+ export function refusal(r: RawRunner, segment?: string): string {
77
+ const alt = r.iterate == null ? '' : `, or \`${r.iterate}\` to iterate on ONE suite while you fix a bug`
78
+ const which = segment == null || segment.trim() === '' || segment.trim() === r.head ? '' : ` (refused on \`${segment.trim().slice(0, 80)}\`)`
79
+ return `\`${r.head}\`${which} bypasses shipkit, so nothing is cached and no other agent's run can be attached to. Use \`${r.use}\`${alt}. Lifting this for good: delete the "Bash(${r.head}*)" line from .claude/settings.json permissions.deny (build/shipkit.md, ONE WAY IN).`
80
+ }
81
+
82
+ /** shell wrappers that do not change what is being run */
83
+ const WRAPPERS = new Set(['time', 'timeout', 'nice', 'nohup', 'stdbuf', 'command', 'builtin', 'sudo', 'env'])
84
+
85
+ /** the heads a settings file actually denies. The guard asks THE FILE rather
86
+ * than the table, so deleting a `Bash(...)` line lifts the deny AND the
87
+ * hook's refusal in one edit: two mechanisms an agent has to find and lift
88
+ * separately is the deny it cannot escape, which is worse than the disease */
89
+ export function deniedHeads(settings: unknown): Set<string> {
90
+ const deny = (settings as { permissions?: { deny?: unknown } } | null)?.permissions?.deny
91
+ const out = new Set<string>()
92
+ if (!Array.isArray(deny)) return out
93
+ for (const rule of deny) {
94
+ const m = typeof rule === 'string' ? /^Bash\((.+?)\*?\)$/.exec(rule) : null
95
+ if (m != null) out.add(m[1]!.trim())
96
+ }
97
+ return out
98
+ }
99
+
100
+ /** a word that could be the name of a program. Anything carrying a quote, a
101
+ * parenthesis or a bracket is prose or code, not an invocation, and matching
102
+ * it is how a guard earns a reputation for crying wolf */
103
+ const PROGRAM_WORD = /^[\w./@+-]+$/
104
+
105
+ /** split a command line on the shell operators, at QUOTE DEPTH ZERO only.
106
+ * Without that, `git commit -m "why cd x && bun test is refused"` splits
107
+ * inside the message and the second half reads as a command. House commit
108
+ * bodies are dense and multi-line, so this is not a corner case here, it is
109
+ * the most common shape of command an agent runs. */
110
+ export function shellSegments(command: string): string[] {
111
+ // everything after a heredoc opener is the document, never a command
112
+ const shell = command.split(/<<-?\s*['"]?\w+/)[0] ?? command
113
+ const out: string[] = []
114
+ let cur = ''
115
+ let quote: '"' | "'" | null = null
116
+ for (let i = 0; i < shell.length; i++) {
117
+ const ch = shell[i]!
118
+ if (quote != null) {
119
+ cur += ch
120
+ if (ch === '\\' && quote === '"') { cur += shell[++i] ?? ''; continue }
121
+ if (ch === quote) quote = null
122
+ continue
123
+ }
124
+ if (ch === '"' || ch === "'") { quote = ch; cur += ch; continue }
125
+ if (ch === '\n' || ch === ';' || ch === '|' || ch === '&') {
126
+ // `&&` and `||` are one operator, and a lone `&` backgrounds
127
+ if ((ch === '&' || ch === '|') && shell[i + 1] === ch) i++
128
+ out.push(cur)
129
+ cur = ''
130
+ continue
131
+ }
132
+ cur += ch
133
+ }
134
+ out.push(cur)
135
+ return out
136
+ }
137
+
138
+ /** the raw runner this shell command reaches for, plus the segment that
139
+ * offended, or null.
140
+ *
141
+ * Deliberately conservative in BOTH directions. It must catch the forms a
142
+ * prefix deny rule misses (a leading `CI=1`, `./node_modules/.bin/tsc`), and
143
+ * it must never fire on a command that merely CONTAINS the words: a heredoc
144
+ * body is data and so is a quoted commit message, and a false refusal teaches
145
+ * an agent to route around the whole file. So: the heredoc body is dropped,
146
+ * segments are cut at quote depth zero, each is reduced to its first program
147
+ * word, and that word must match a head exactly or as the basename of a path.
148
+ * Longest head first, so `bunx tsc` is never reported as `tsc`. `only`
149
+ * narrows to the heads this repo really denies. */
150
+ export function rawRunnerIn(command: string, only?: ReadonlySet<string>): RawRunner | null {
151
+ return rawRunnerHit(command, only)?.runner ?? null
152
+ }
153
+
154
+ export function rawRunnerHit(command: string, only?: ReadonlySet<string>): { runner: RawRunner; segment: string } | null {
155
+ const heads = [...RAW_RUNNERS]
156
+ .filter((r) => only == null || only.has(r.head))
157
+ .sort((a, b) => b.head.length - a.head.length)
158
+ for (const seg of shellSegments(command)) {
159
+ let s = seg.trim().replace(/^[(){}\s]+/, '')
160
+ // leading VAR=value assignments, then a wrapper word, repeatedly. A
161
+ // wrapper's own flags and durations go with it (`timeout 60 bun test`)
162
+ let wrapped = false
163
+ for (;;) {
164
+ const next = s.replace(/^\w+=(?:"[^"]*"|'[^']*'|\S*)\s+/, '')
165
+ if (next !== s) { s = next; continue }
166
+ const w = /^(\S+)\s+/.exec(s)
167
+ if (w != null && WRAPPERS.has(w[1]!.split('/').pop() ?? '')) { s = s.slice(w[0].length); wrapped = true; continue }
168
+ if (wrapped && w != null && /^(?:-\S*|\d+[smhd]?)$/.test(w[1]!)) { s = s.slice(w[0].length); continue }
169
+ break
170
+ }
171
+ const words = s.split(/\s+/).filter((w) => w !== '')
172
+ const first = words[0]
173
+ if (first == null || !PROGRAM_WORD.test(first)) continue
174
+ // a path-qualified binary counts: ./node_modules/.bin/tsc IS tsc
175
+ const program = first.split('/').pop() ?? first
176
+ for (const r of heads) {
177
+ const parts = r.head.split(' ')
178
+ if (parts[0] !== program) continue
179
+ if (parts.length === 1 || words[1] === parts[1]) return { runner: r, segment: seg }
180
+ }
181
+ }
182
+ return null
183
+ }
@@ -15,6 +15,8 @@ import { RULES } from '../rules/rule-registry.ts'
15
15
  import { runRuleLint } from '../rules/rule.ts'
16
16
  import { formatLine, parseWireOutput, type Line } from './wire.ts'
17
17
  import { writeStatus } from './write-status.ts'
18
+ import { SHARE_ENV } from '../release/releases-share.ts'
19
+ import { rmSync } from 'fs'
18
20
 
19
21
  // orchestrator cache for checks WITHOUT their own suite — short on purpose:
20
22
  // this layer only absorbs back-to-back runs, real TTLs live in the checks
@@ -42,6 +44,21 @@ export type CheckOpts = {
42
44
  * spends a third-party API call per publish. Undeclared checks are still
43
45
  * collected, because an unclassified check stays fatal. */
44
46
  onlyBlocking?: boolean
47
+ /** the caller narrowed the run to these ids (`ci --only`). Empty or absent
48
+ * means everything. A `--only` that narrowed TASKS while every check still
49
+ * ran made the flag actively harmful: `ci --only cycles`, a 0.03s task,
50
+ * probed every third-party service the repo knows about. One flag, one
51
+ * meaning, across all three surfaces. */
52
+ only?: string[]
53
+ }
54
+
55
+ /** does this rule or check answer to one of the `--only` ids? Matched against
56
+ * every name the surface is known by (a rule's id AND its title, a discovered
57
+ * check's name AND its path), because the id an operator types is whichever
58
+ * one they last saw on the wire. */
59
+ export function matchesOnly(only: string[] | undefined, ...names: string[]): boolean {
60
+ if (only == null || only.length === 0) return true
61
+ return only.some((want) => names.some((n) => n.toLowerCase() === want.toLowerCase()))
45
62
  }
46
63
 
47
64
  export type CheckRun = {
@@ -85,26 +102,41 @@ export async function collectChecks(root: string, o: CheckOpts = {}): Promise<Ch
85
102
  t.warned = true
86
103
  }
87
104
  for (const rule of [...RULES, ...(c.rules ?? [])]) {
105
+ if (!matchesOnly(o.only, rule.id, rule.title)) continue
88
106
  if (o.onlyBlocking === true && !rule.blocking) continue
89
107
  suite.addCheck({
108
+ blocking: rule.blocking,
90
109
  name: rule.title,
91
110
  remainValidFor: rule.ttl,
92
111
  cacheExtra: rule.cacheKey != null ? () => rule.cacheKey!({ root, config: c }) : undefined,
93
112
  logic: async (t) => { await runRuleLint(rule, t, { root, config: c }); demote(t, rule.blocking, rule.title) },
94
113
  })
95
114
  }
115
+ // ONE release survey for the whole run: five discovered checks call
116
+ // collectReports, each in its own subprocess, and the survey is ~4s of
117
+ // network. The path is per RUN and never reused across runs, so nothing
118
+ // here can serve a stale answer to a later gate (releases-share.ts).
119
+ // a caller that already opened one (runCi, which starts the survey next to
120
+ // the tasks) owns it, cleanup included; otherwise this run owns its own
121
+ const inherited = process.env[SHARE_ENV]
122
+ const sharePath = inherited != null && inherited !== '' ? inherited : join(root, 'tmp/shipkit-ci', `releases-${process.pid}-${Date.now()}.json`)
96
123
  const childEnv: Record<string, string | undefined> = {
97
124
  ...process.env,
125
+ [SHARE_ENV]: sharePath,
98
126
  ...(o.fresh === true ? { SHIPKIT_FRESH: '1', RV_CHECK_FRESH: '1' } : {}),
99
127
  }
100
128
  for (const d of discoverChecks(root)) {
101
129
  const rel = relative(root, d.path)
102
130
  // file wins, then the repo default, then nothing: an unclassified
103
131
  // check is an error, never a guess
132
+ if (!matchesOnly(o.only, d.name, rel)) continue
104
133
  const blocking = d.blocking ?? c.checkDefaults?.blocking
105
134
  if (blocking == null) undeclared.push(rel)
106
135
  if (o.onlyBlocking === true && blocking !== true) continue
107
136
  suite.addCheck({
137
+ // a check about the WORLD may remember a measured negative; one
138
+ // about this repo never does (check-suite.ts, the rule)
139
+ blocking: blocking ?? true,
108
140
  name: d.name,
109
141
  remainValidFor: d.selfCached ? undefined : DISCOVERED_TTL,
110
142
  logic: async (t) => {
@@ -121,6 +153,8 @@ export async function collectChecks(root: string, o: CheckOpts = {}): Promise<Ch
121
153
  })
122
154
  }
123
155
  const r = await suite.execute()
156
+ // the survey dies with the run that made it, and only the run that made it
157
+ if (inherited == null || inherited === '') for (const f of [sharePath, `${sharePath}.lock`]) { try { rmSync(f, { force: true }) } catch {} }
124
158
  if (prevFresh == null) delete process.env.SHIPKIT_FRESH; else process.env.SHIPKIT_FRESH = prevFresh
125
159
  if (prevFrozen == null) delete process.env.SHIPKIT_FROZEN; else process.env.SHIPKIT_FROZEN = prevFrozen
126
160
  // frozen = a release-shaped run: reporting would re-dirty the tree the
@@ -13,6 +13,11 @@ import { runFixPass } from './run-fixers.ts'
13
13
  import { formatLine } from './wire.ts'
14
14
  import { runTasks, type TaskResult } from './task-runner.ts'
15
15
  import { writeStatus } from './write-status.ts'
16
+ import { rmSync } from 'fs'
17
+
18
+ import { SHARE_ENV } from '../release/releases-share.ts'
19
+ import { collectReports } from '../release/run-releases.ts'
20
+ import { withContractsScope } from '../contracts/contracts-once.ts'
16
21
 
17
22
  export type CiOpts = {
18
23
  bail?: boolean
@@ -54,8 +59,22 @@ export type CiOpts = {
54
59
  * loads of repo.config.ts can disagree, and the release path classifies
55
60
  * rule-vs-probe severity from the config it holds. */
56
61
  config?: RepoConfig
62
+ /** which test tier the run asks the repo's suite for. 'quick' is the
63
+ * working default; 'full' sets SHIPKIT_TEST_FULL=1 for every task, which
64
+ * a suite reads to include the slow, real-world tiers it otherwise skips.
65
+ * The release path always asks for 'full': a version published behind a
66
+ * partial suite is a green nobody measured. Default reads the env var, so
67
+ * `SHIPKIT_TEST_FULL=1 shipkit ci` works with no flag. */
68
+ tests?: TestTier
57
69
  }
58
70
 
71
+ export type TestTier = 'quick' | 'full'
72
+
73
+ /** the env var a repo's own suite reads to widen itself. Named here because
74
+ * the gate is what sets it, and a suite that invents its own name is a tier
75
+ * the release path cannot reach. */
76
+ export const FULL_TESTS_ENV = 'SHIPKIT_TEST_FULL'
77
+
59
78
  /** what STATUS.md may claim about this run. `--with` ADDS a task, so it stays
60
79
  * the full gate; `--only`, `--skip` and skipChecks each remove coverage, and
61
80
  * a narrowed run that stamps the full gate's verdict is a green nobody
@@ -71,24 +90,69 @@ function ciScope(o: CiOpts): { command: string; fullGate: boolean } {
71
90
  }
72
91
 
73
92
  export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
93
+ // ONE gate run: the `contracts` task evaluates, rule SK002 reads what it
94
+ // computed instead of evaluating a second time (contracts-once.ts)
95
+ return await withContractsScope(() => runCiInScope(root, o))
96
+ }
97
+
98
+ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
74
99
  const print = o.print ?? ((l: string) => console.log(l))
75
100
  const collected: string[] = []
76
101
  const sink = (l: string): void => { collected.push(l); print(l) }
77
102
  const c = o.config ?? (await loadRepoConfig(root))
78
103
  // mechanical failures are not information, so they never cost a round trip
79
104
  let fixFailed = false
105
+ // a NARROWED run repairs only the mechanical slots. The structural rule
106
+ // fixers rewrite package.json and repo config, and arming rewrites hooks/:
107
+ // that is the right price for the full gate and the wrong one for
108
+ // `bun run test`, which routes here and must answer the one question its
109
+ // name promises without leaving files in the agent's diff that it never
110
+ // wrote. Under CI it is worse than surprising, a formatting rewrite comes
111
+ // back as a test failure.
112
+ const narrowed = (o.onlyIds ?? []).length > 0
80
113
  if (o.fix !== false)
81
- fixFailed = (await runFixPass(root, c, { rules: o.fixRules !== false, armHooks: o.armHooks === true, print: sink })).failed
114
+ fixFailed = (await runFixPass(root, c, { rules: !narrowed && o.fixRules !== false, armHooks: !narrowed && o.armHooks === true, print: sink })).failed
82
115
  else sink('skip: fix pass (--no-fix)')
83
116
  const all = allTasks(c, root)
84
117
  const { disabled, unfilled } = all
85
118
  const skipIds = new Set(o.skipTaskIds ?? [])
86
- const tasks = all.tasks.filter((t) => !skipIds.has(t.id))
119
+ const tier: TestTier = o.tests ?? (process.env[FULL_TESTS_ENV] === '1' ? 'full' : 'quick')
120
+ const tasks = all.tasks
121
+ .filter((t) => !skipIds.has(t.id))
122
+ // the whole run carries the flag rather than one task id: a repo names
123
+ // its suites what it likes (`test`, `test:libs`, a prepublish script),
124
+ // and a gate that guesses which one is the tests will miss one
125
+ .map((t) => (tier === 'full' ? { ...t, env: { ...t.env, [FULL_TESTS_ENV]: '1' } } : t))
126
+ // announced BEFORE the run, so a log that died mid-way still says which
127
+ // tier it was asking for
128
+ sink(tier === 'full'
129
+ ? `ok: test tier FULL — every suite, the slow ones included (${FULL_TESTS_ENV}=1)`
130
+ : `skip: full test tier (quick run — \`shipkit ci --full\` or ${FULL_TESTS_ENV}=1 includes it)`)
87
131
  for (const t of all.tasks) if (skipIds.has(t.id)) sink(`skip: ${t.id} (not part of this release, the unit's own gate covers it)`)
88
132
  for (const d of disabled) sink(`skip: ${d} (skipped-by-config)`)
89
133
  // a floor slot nothing could fill is VISIBLE — a thin gate must never
90
134
  // look like a full one (the fresh-repo floor gap)
91
- for (const u of unfilled) sink(`skip: ${u.id} (${u.reason})`)
135
+ for (const u of unfilled) {
136
+ sink(`skip: ${u.id} (${u.reason})`)
137
+ // asked for BY NAME and unfillable: this is where an agent lands after
138
+ // the harness refused it the raw runner, so the line says what to add
139
+ // rather than leaving it with `unknown task id` and no next move
140
+ if ((o.onlyIds ?? []).includes(u.id))
141
+ sink(` fix: add a '${u.id}:raw' script holding the tool, then \`shipkit ci --only ${u.id}\` runs it (meta/rv-conventions/package-json.md)`)
142
+ }
143
+ // the release survey is ~4s of network that five discovered checks need
144
+ // and the TASKS do not. Started HERE it overlaps lint/typecheck/test and
145
+ // costs the run nothing; started inside the first check that wants it, it
146
+ // is 4s nobody can overlap. Failures are the checks' to report, not this
147
+ // line's: a survey nobody can run just means the checks do it themselves.
148
+ const sharePath = join(root, 'tmp/shipkit-ci', `releases-${process.pid}-${Date.now()}.json`)
149
+ const priorShare = process.env[SHARE_ENV]
150
+ process.env[SHARE_ENV] = sharePath
151
+ // a NARROWED run pre-warms nothing: `--only cycles` asks one question, and
152
+ // paying a 4s network survey to answer it is the same bug --only was
153
+ // fixed for
154
+ const willRunChecks = o.skipChecks !== true && (o.onlyIds ?? []).length === 0
155
+ const survey = willRunChecks ? collectReports(root, c).catch(() => null) : null
92
156
  const summary = await runTasks({
93
157
  tasks,
94
158
  logDir: c.logDir ?? join(root, 'tmp/shipkit-ci'),
@@ -100,11 +164,23 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
100
164
  print: sink,
101
165
  onTaskFinish: o.onTaskFinish,
102
166
  })
167
+ // what the suite ACTUALLY ran, from its own summary line. A flag that was
168
+ // set proves the intent; the count proves the tests
169
+ const ranTests = summary.results
170
+ .map((r) => /Ran (\d+) tests?/.exec(r.lastLine ?? '')?.[1])
171
+ .find((n) => n != null)
172
+ if (ranTests != null) sink(`ok: test tier ${tier}: ${ranTests} tests ran`)
103
173
  // the checks, in the SAME command. there is no second surface: a check
104
174
  // declares whether it blocks, and that is the only question about it.
175
+ // a narrowed run says what it did NOT look at: a green that only ever
176
+ // asked one question must not read like the full gate
177
+ if ((o.onlyIds ?? []).length > 0 && o.skipChecks !== true) sink(`skip: checks not named by --only (${(o.onlyIds ?? []).join(', ')})`)
178
+ if (survey != null) await survey
105
179
  const checks = o.skipChecks === true
106
180
  ? { lines: [], blocked: false, advisoryFailures: [], undeclared: [] }
107
- : await collectChecks(root, { status: false, config: c, frozen: o.frozen, onlyBlocking: o.onlyBlockingChecks })
181
+ : await collectChecks(root, { status: false, config: c, frozen: o.frozen, onlyBlocking: o.onlyBlockingChecks, only: o.onlyIds })
182
+ if (priorShare == null) delete process.env[SHARE_ENV]; else process.env[SHARE_ENV] = priorShare
183
+ for (const f of [sharePath, `${sharePath}.lock`]) { try { rmSync(f, { force: true }) } catch {} }
108
184
  const color = process.stdout.isTTY && process.env.NO_COLOR == null
109
185
  for (const l of checks.lines) { collected.push(formatLine(l, false)); print(formatLine(l, color)) }
110
186
  for (const u of checks.undeclared) {
@@ -14,6 +14,7 @@ import { fileURLToPath } from 'url'
14
14
  import { armHooks } from '../hooks/run-hooks.ts'
15
15
  import type { RepoConfig } from '../repo-config.ts'
16
16
  import { runFix } from '../rules/run-fix.ts'
17
+ import { routesToShipkit } from './canonical-scripts.ts'
17
18
  import { spawnWithTimeout } from './spawn-timeout.ts'
18
19
 
19
20
  const FIXER_TIMEOUT_MS = 120_000
@@ -60,8 +61,12 @@ export function resolveFixers(root: string, config?: RepoConfig): FixerResolutio
60
61
  return true
61
62
  }
62
63
 
63
- const fmtName = FMT_SCRIPTS.find((n) => scripts[n] != null)
64
+ // `fmt` is the one slot the fix pass invokes BY SCRIPT, so it is the one
65
+ // name ONE WAY IN deliberately leaves raw: a routed body here would make
66
+ // the fixer spawn the gate that spawns the fixer
67
+ const fmtName = FMT_SCRIPTS.find((n) => scripts[n] != null && !routesToShipkit(scripts[n]!))
64
68
  if (fmtName != null) slots.push({ id: 'fmt', cmd: ['bun', 'run', fmtName], source: 'package.json script' })
69
+ else if (FMT_SCRIPTS.some((n) => scripts[n] != null)) unfilled.push({ id: 'fmt', reason: `the 'fmt' script routes back to shipkit — the fix pass runs it, so it holds the formatter itself` })
65
70
  else unfilled.push({ id: 'fmt', reason: `no 'fmt' or 'format' script — formatting stays a manual step` })
66
71
 
67
72
  if (offByConfig('imports:fix')) { /* the repo said no, in the one place that governs both directions */ }
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env bun
2
+ // the PreToolUse guard for ONE WAY IN (core/one-way-in-policy.ts owns the
3
+ // table and the wording). Its ONE job is to refuse a raw runner with the
4
+ // shipkit command to type instead: `permissions.deny` already blocks these,
5
+ // but a bare deny tells the model only that it was denied, and a refusal with
6
+ // no replacement just sends it looking for another way in.
7
+ //
8
+ // It runs before EVERY Bash call, so it stays a leaf: one tiny import, one
9
+ // small json read, no clipanion, no repo config. Anything it cannot decide it
10
+ // waves through, because a guard that fails closed on its own bugs would wedge
11
+ // a repo with no way to tell why, and `permissions.deny` is still the floor
12
+ // underneath it. Measured at ~10ms per call.
13
+ import { deniedHeads, rawRunnerHit, refusal } from '../core/one-way-in-policy.ts'
14
+
15
+ const input = await Bun.stdin.text()
16
+ let command = ''
17
+ try {
18
+ const ev = JSON.parse(input) as { tool_name?: string; tool_input?: { command?: string } }
19
+ if (ev.tool_name !== 'Bash') process.exit(0)
20
+ command = ev.tool_input?.command ?? ''
21
+ } catch {
22
+ process.exit(0)
23
+ }
24
+ // the settings file is the authority on WHICH runners this repo refuses, so
25
+ // deleting one `Bash(...)` line lifts the deny and this refusal together
26
+ const settingsPath = `${process.env['CLAUDE_PROJECT_DIR'] ?? process.cwd()}/.claude/settings.json`
27
+ let denied: Set<string>
28
+ try {
29
+ denied = deniedHeads(await Bun.file(settingsPath).json())
30
+ } catch {
31
+ process.exit(0)
32
+ }
33
+ const hit = command === '' || denied.size === 0 ? null : rawRunnerHit(command, denied)
34
+ if (hit == null) process.exit(0)
35
+ // exit 2 is the block: stderr becomes the reason the model reads
36
+ // naming the segment earns its place only when the command had MORE in it:
37
+ // on a bare call it repeats the head back at the reader
38
+ console.error(refusal(hit.runner, hit.segment.trim() === command.trim() ? undefined : hit.segment))
39
+ process.exit(2)
package/src/init-repo.ts CHANGED
@@ -9,7 +9,8 @@
9
9
  import { existsSync, readFileSync, writeFileSync } from 'fs'
10
10
  import { join } from 'path'
11
11
  import { canonicalBodies } from './core/canonical-scripts.ts'
12
- import { hasTestFiles } from './rules/SK001-package-json-shape.ts'
12
+ import { hasTestFiles, packageJsonShape } from './rules/SK001-package-json-shape.ts'
13
+ import { oneWayInPolicy } from './rules/SK013-one-way-in-policy.ts'
13
14
  import { reportToMotherhome } from './core/motherhome.ts'
14
15
  import { ensureGitRepo, publishRepo, type Run } from './git-bootstrap.ts'
15
16
  import { defineRepo, loadRepoConfig, resolveFloor } from './repo-config.ts'
@@ -67,6 +68,9 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
67
68
  const cfg = await loadRepoConfig(root)
68
69
  const g = await gitignorePolicy.fix!({ root, config: cfg })
69
70
  p(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
71
+ // the repos that need ONE WAY IN most are the ones that already exist,
72
+ // so a re-run REPAIRS rather than only seeding a fresh checkout
73
+ await repairOneWayIn(root, cfg, p)
70
74
  const mh = reportToMotherhome(root, cfg)
71
75
  if (mh.note != null) p(mh.note)
72
76
  if (wired && opts.noPublish !== true) await publishRepo(root, { github: opts.github, gh: opts.gh, print: p })
@@ -99,6 +103,7 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
99
103
  // `shipkit check --fix SK004`, one code path
100
104
  const g = await gitignorePolicy.fix!({ root, config: defineRepo({ name }) })
101
105
  p(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
106
+ await repairOneWayIn(root, defineRepo({ name }), p)
102
107
  // what the gate actually runs, in ONE line: a thin floor must be seen at
103
108
  // init time rather than discovered after a bad commit slipped through.
104
109
  // The per-task resolution is diagnostics, and a `skip:` naming a script
@@ -128,3 +133,16 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
128
133
  if (opts.noPublish !== true) p('next: `shipkit ci` is the gate — run it to check your work')
129
134
  return 0
130
135
  }
136
+
137
+ /** ONE WAY IN, both halves, through the SAME fixers `shipkit check --fix` runs
138
+ * (build/shipkit.md). Nothing here is a second implementation: rule SK001
139
+ * routes the package.json scripts, rule SK013 writes the harness policy, and
140
+ * init just calls them so a wired repo is born enforced and a re-run repairs
141
+ * one that was not. The deny is a RESTRICTION, which is why it lands
142
+ * unconditionally where the scaffold's allow list stays opt in. */
143
+ async function repairOneWayIn(root: string, config: Parameters<typeof defineRepo>[0], p: (line: string) => void): Promise<void> {
144
+ const pkg = await packageJsonShape.fix!({ root, config })
145
+ if (pkg.changed) p(`ok: package.json — ${pkg.summary}`)
146
+ const policy = await oneWayInPolicy.fix!({ root, config })
147
+ p(policy.changed ? `ok: ${policy.summary}` : `ok: .claude/settings.json already closes every raw runner`)
148
+ }