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.
- package/CHANGELOG.md +18 -0
- package/README.md +3 -0
- package/package.json +4 -1
- package/src/cli/cmd_Bump.ts +2 -0
- package/src/cli/cmd_Ci.ts +3 -1
- package/src/cli/cmd_Pb.ts +27 -0
- package/src/cli/cmd_Release.ts +1 -1
- package/src/cli/cmd_Wiki.ts +2 -2
- package/src/cli.ts +49 -39
- package/src/collections/providers.ts +7 -2
- package/src/contracts/contracts-once.ts +43 -0
- package/src/core/canonical-scripts.ts +35 -0
- package/src/core/check-ctx.ts +13 -0
- package/src/core/check-suite.ts +121 -53
- package/src/core/floor.ts +82 -0
- package/src/core/motherhome.ts +14 -1
- package/src/core/one-way-in-policy.ts +183 -0
- package/src/core/run-check.ts +34 -0
- package/src/core/run-ci.ts +80 -4
- package/src/core/run-fixers.ts +6 -1
- package/src/hooks/one-way-in-guard.ts +39 -0
- package/src/init-repo.ts +19 -1
- package/src/ledger/toolchain-identity.ts +134 -0
- package/src/pb/collection-scaffold.ts +299 -0
- package/src/pb/pb-types.ts +233 -0
- package/src/pb/run-pb.ts +157 -0
- package/src/release/git-facts.ts +3 -2
- package/src/release/release-scope.ts +52 -0
- package/src/release/releases-share.ts +85 -0
- package/src/release/run-bump.ts +31 -8
- package/src/release/run-release.ts +8 -1
- package/src/release/run-releases.ts +20 -6
- package/src/repo-config.ts +22 -65
- package/src/rules/SK001-package-json-shape.ts +65 -1
- package/src/rules/SK002-contracts-fulfillness.ts +2 -2
- package/src/rules/SK004-gitignore-policy.ts +27 -3
- package/src/rules/SK012-config-barrel-import.ts +60 -0
- package/src/rules/SK013-one-way-in-policy.ts +136 -0
- package/src/rules/rule-registry.ts +4 -0
- package/src/rules/untrack-generated.ts +117 -0
- package/src/scaffold/files/claude-settings-local.json +0 -3
- package/src/scaffold/files/verify-skill.md +1 -1
- package/src/scaffold/project-scaffold.ts +1 -1
- package/src/serve/hub-port.ts +17 -0
- package/src/serve/run-serve.ts +2 -5
- 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
|
+
}
|
package/src/core/motherhome.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/core/run-check.ts
CHANGED
|
@@ -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
|
package/src/core/run-ci.ts
CHANGED
|
@@ -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
|
|
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)
|
|
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) {
|
package/src/core/run-fixers.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|