rvlib-shipkit 1.9.0 → 2.0.1
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 +38 -0
- package/package.json +1 -1
- package/src/check.ts +5 -2
- package/src/checks/SK001-package-json-shape.check.ts +47 -12
- package/src/checks/SK013-one-way-in-policy.check.ts +8 -7
- package/src/checks/SK017-vscode-hub-task.check.ts +109 -106
- package/src/checks/SK019-tests-run-in-units.check.ts +2 -1
- package/src/checks/SK027-agent-wiki-links.check.ts +5 -2
- package/src/checks/SK029-explicit-check-files.check.ts +25 -8
- package/src/checks/SK034-commit-gates-armed.check.ts +90 -0
- package/src/cli/cmd_Ci.ts +3 -3
- package/src/cli/cmd_Commands.ts +21 -5
- package/src/cli/cmd_Hooks.ts +6 -2
- package/src/cli/cmd_Init.ts +1 -1
- package/src/cli/cmd_Serve.ts +1 -2
- package/src/cli/cmd_Start.ts +1 -1
- package/src/cli.ts +2 -4
- package/src/core/budget.ts +6 -8
- package/src/core/bun-builtins.ts +36 -0
- package/src/core/bun-tool-cli.ts +23 -0
- package/src/core/canonical-scripts.ts +6 -4
- package/src/core/check-ctx.ts +104 -10
- package/src/core/check-file-names.ts +2 -3
- package/src/core/check-pass.ts +17 -1
- package/src/core/check-runner.ts +19 -6
- package/src/core/config-rewrite.ts +27 -6
- package/src/core/discover-checks.ts +30 -10
- package/src/core/floor-resolve.ts +16 -7
- package/src/core/job-run.ts +2 -2
- package/src/core/layout.ts +4 -1
- package/src/core/legacy-shapes.ts +40 -0
- package/src/core/one-lib.ts +1 -1
- package/src/core/one-way-in-policy.ts +106 -13
- package/src/core/pre-commit-discovery.ts +106 -0
- package/src/core/retired-checks.ts +2 -0
- package/src/core/run-ci.ts +16 -10
- package/src/core/run-fix.ts +9 -0
- package/src/core/run-fixers.ts +91 -35
- package/src/core/runs-matrix.ts +7 -0
- package/src/core/slots.ts +1 -1
- package/src/core/vendored-read.ts +39 -0
- package/src/core/vendored.ts +82 -0
- package/src/core/waivers.ts +125 -0
- package/src/core/when-block.ts +25 -185
- package/src/core/when.ts +30 -13
- package/src/core/wire.ts +17 -1
- package/src/fleet/update-fleet.ts +11 -2
- package/src/git-bootstrap.ts +1 -1
- package/src/home/attach.ts +3 -5
- package/src/home/insights.ts +6 -1
- package/src/home/pm-config.ts +2 -2
- package/src/home/run-daemon.ts +2 -2
- package/src/home/run-unit-action.ts +2 -2
- package/src/hooks/commit-message.ts +2 -14
- package/src/hooks/hook-gates.ts +38 -0
- package/src/hooks/leak-guard.ts +8 -3
- package/src/hooks/one-way-in-guard.ts +8 -2
- package/src/hooks/root-identity.ts +9 -6
- package/src/hooks/run-hooks.ts +72 -41
- package/src/hooks/staged.ts +49 -0
- package/src/hooks/tree-shrink.ts +41 -0
- package/src/init-repo.ts +0 -42
- package/src/ledger/check-table.ts +1 -1
- package/src/ledger/gate-checks.ts +45 -9
- package/src/ledger/ledger.ts +6 -1
- package/src/ledger/repo-view.ts +14 -4
- package/src/ledger/toolchain-identity.ts +4 -2
- package/src/mv/mv-md.ts +6 -1
- package/src/problems/extract.ts +27 -5
- package/src/problems/problem-line.ts +2 -3
- package/src/release/run-release.ts +13 -8
- package/src/repo-config.ts +44 -27
- package/src/scaffold/files/bunfig.toml +11 -0
- package/src/scaffold/files/vscode-settings.json +1 -2
- package/src/scaffold/jsonc-spans.ts +16 -0
- package/src/scaffold/project-scaffold.ts +43 -77
- package/src/serve/hub-app/check-view.tsx +4 -4
- package/src/serve/hub-app/check-words.ts +1 -0
- package/src/serve/hub-app/checks-svc.ts +1 -2
- package/src/serve/hub-app/home.html +0 -1
- package/src/serve/hub-app/hub-types.ts +0 -2
- package/src/serve/hub-app/index.html +0 -1
- package/src/serve/hub-app/project-list.css +1 -2
- package/src/serve/hub-app/tree-build.ts +3 -2
- package/src/serve/hub-app/tree-svc.ts +2 -2
- package/src/serve/hub-port.ts +3 -3
- package/src/serve/hub-runtime.ts +0 -3
- package/src/serve/serve-hub.ts +1 -16
- package/src/serve/tracked-watch.ts +1 -1
- package/src/start-repo.ts +15 -3
- package/src/suite/judge-suites.ts +29 -28
- package/src/suite/select.ts +2 -0
- package/src/suite/suite-cli.ts +6 -3
- package/src/suite/suite-cpu-preload.ts +7 -11
- package/src/suite/test-units.ts +7 -4
- package/src/ts/config.ts +12 -5
- package/src/ts/imports.ts +3 -1
- package/src/tui/build-steps-panel.ts +1 -1
- package/src/wiki/scan-md.ts +16 -9
- package/src/checks/SK009-commit-ai-tells.check.ts +0 -131
- package/src/checks/SK014-prose-ste.check.ts +0 -78
- package/src/cli/cmd_Prose.ts +0 -74
- package/src/prose/prose-view.ts +0 -90
- package/src/prose/run-prose.ts +0 -187
- package/src/prose/ste.ts +0 -552
- package/src/scaffold/files/vscode-tasks.json +0 -24
- package/src/serve/hub-app/prose-hits-st.ts +0 -125
- package/src/serve/hub-app/prose-hits-view.tsx +0 -176
- package/src/serve/hub-app/prose-hits.css +0 -21
package/src/home/run-daemon.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// at once (lib-watch.ts): the new image follows the jobs still running. The
|
|
8
8
|
// machine resources are pm's.
|
|
9
9
|
import { fileURLToPath } from 'url'
|
|
10
|
-
import {
|
|
10
|
+
import { installOneLib } from '../core/one-lib.ts'
|
|
11
11
|
import { reclaimPreviousImage } from '../core/instance.ts'
|
|
12
12
|
import { reloadOnOwnCode } from './lib-watch.ts'
|
|
13
13
|
import { loadHub } from '../serve/hub-runtime.ts'
|
|
@@ -47,7 +47,7 @@ export async function runDaemon(root: string, io: HomeIo, o: { port?: number } =
|
|
|
47
47
|
}
|
|
48
48
|
// before the first repo config loads: every one of them, and every check
|
|
49
49
|
// file, then imports this copy of the lib
|
|
50
|
-
|
|
50
|
+
installOneLib()
|
|
51
51
|
// a process this one starts runs its gate and its hub in process
|
|
52
52
|
process.env[WORKER_ENV] = '1'
|
|
53
53
|
// a restart in place keeps the pid: the check children its previous
|
|
@@ -12,8 +12,8 @@ if (import.meta.main) {
|
|
|
12
12
|
console.error('usage: bun run-unit-action.ts <unit file> <action id>')
|
|
13
13
|
process.exit(2)
|
|
14
14
|
}
|
|
15
|
-
const {
|
|
16
|
-
|
|
15
|
+
const { installOneLib } = await import('../core/one-lib.ts')
|
|
16
|
+
installOneLib()
|
|
17
17
|
const u = ((await import(file)) as { default?: GlobalProcessUnit }).default
|
|
18
18
|
const run = u?.actions?.[id]?.run
|
|
19
19
|
if (run == null) {
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
// the commit-message predicate of the commit-msg hook, which blocks at write
|
|
2
|
-
// time
|
|
3
|
-
//
|
|
4
|
-
// mechanical text rules only, so this stays publishable.
|
|
2
|
+
// time. mechanical text rules only, so this stays publishable.
|
|
5
3
|
|
|
6
|
-
export type OffenseKind = 'dash' | '
|
|
4
|
+
export type OffenseKind = 'dash' | 'language'
|
|
7
5
|
export type Offense = { line: number; text: string; kind: OffenseKind; why: string }
|
|
8
6
|
|
|
9
7
|
/** git surfaces are English, always (identity/writing-voice.md). cheap French
|
|
@@ -34,13 +32,6 @@ export function frenchHits(line: string, languageAllow: string[] = []): boolean
|
|
|
34
32
|
return words.size >= 2
|
|
35
33
|
}
|
|
36
34
|
|
|
37
|
-
/** trailers and footers that name an assistant, assistant session trailers,
|
|
38
|
-
* assistant share-domain urls (a session link is a louder tell than any
|
|
39
|
-
* trailer), plus the robot emoji. the session/url family lists every
|
|
40
|
-
* assistant, not just the one that bit first. */
|
|
41
|
-
export const AI_ATTRIBUTION =
|
|
42
|
-
/(?:co-authored-by|assisted-by|signed-off-by):[^\n]*\b(?:claude|anthropic|copilot|chatgpt|openai|cursor|aider|devin|gemini)\b|generated with[^\n]*\b(?:claude|copilot|chatgpt|gemini)\b|\b(?:claude|chatgpt|copilot|cursor|aider|devin|gemini|codex)-session:|\b(?:claude\.(?:ai|com)|chatgpt\.com|chat\.openai\.com)\b|🤖/i
|
|
43
|
-
|
|
44
35
|
/** everything below git's scissors line is the verbose-mode diff, which git
|
|
45
36
|
* strips before committing, diff content is not ours to police. */
|
|
46
37
|
export function stripNonMessage(message: string): string {
|
|
@@ -62,8 +53,6 @@ export function commitMessageOffenses(message: string, opts?: { languageAllow?:
|
|
|
62
53
|
const body = raw.replace(/^\s*[-*] /, '')
|
|
63
54
|
if (body.includes(' - '))
|
|
64
55
|
offenses.push({ line: i + 1, text: raw, kind: 'dash', why: 'inline " - " separator: same fix' })
|
|
65
|
-
if (AI_ATTRIBUTION.test(raw))
|
|
66
|
-
offenses.push({ line: i + 1, text: raw, kind: 'attribution', why: 'AI attribution: no assistant trailer, session trailer, claude.ai/chatgpt.com url, "Generated with" footer, or robot emoji' })
|
|
67
56
|
if (frenchHits(raw, opts?.languageAllow))
|
|
68
57
|
offenses.push({ line: i + 1, text: raw, kind: 'language', why: 'French: git surfaces are English, always (identity/writing-voice.md); quote product copy in "…" or `…` to exempt it' })
|
|
69
58
|
})
|
|
@@ -71,4 +60,3 @@ export function commitMessageOffenses(message: string, opts?: { languageAllow?:
|
|
|
71
60
|
}
|
|
72
61
|
|
|
73
62
|
export const hasDashPunctuation = (message: string): boolean => commitMessageOffenses(message).some((o) => o.kind === 'dash')
|
|
74
|
-
export const hasAiAttribution = (message: string): boolean => commitMessageOffenses(message).some((o) => o.kind === 'attribution')
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// THE COMMIT GATES the git hooks run, as an inventory. They run from
|
|
2
|
+
// hooks/run-hooks.ts, never through the runner, so the hook stays a leaf that
|
|
3
|
+
// loads no check discovery. This list is what every other surface reads to
|
|
4
|
+
// show them beside the checks (`shipkit checks`, `shipkit when`, the hub) and
|
|
5
|
+
// what `off` names to turn one off. Type imports only: the hook loads it.
|
|
6
|
+
import type { RepoConfig } from '../repo-config.ts'
|
|
7
|
+
import { repoOff, type RepoOff } from '../core/when.ts'
|
|
8
|
+
|
|
9
|
+
/** which git hook runs the gate */
|
|
10
|
+
export type HookName = 'pre-commit' | 'commit-msg' | 'both'
|
|
11
|
+
|
|
12
|
+
export type HookGate = { id: string; hook: HookName; title: string; description: string }
|
|
13
|
+
|
|
14
|
+
export const HOOK_GATES = [
|
|
15
|
+
{ id: 'tree-shrink', hook: 'pre-commit', title: 'tree shrink', description: 'the commit keeps at least half of the files HEAD tracks' },
|
|
16
|
+
{ id: 'leak-guard', hook: 'both', title: 'leak guard', description: 'no staged added line, staged path or commit message matches a banned keyword' },
|
|
17
|
+
{ id: 'blocked-paths', hook: 'pre-commit', title: 'blocked paths', description: 'nothing under a hooks.blockedPaths prefix is staged' },
|
|
18
|
+
{ id: 'root-identity', hook: 'pre-commit', title: 'root identity', description: 'the repo name in package.json and repo.config.ts holds, and no scratch file is staged' },
|
|
19
|
+
{ id: 'commit-message', hook: 'commit-msg', title: 'commit message', description: 'the commit message carries no dash punctuation and no French' },
|
|
20
|
+
{ id: 'commit-language', hook: 'commit-msg', title: 'commit language', description: 'the commit message carries no French, the half of commit-message that off lifts alone' },
|
|
21
|
+
] as const satisfies readonly HookGate[]
|
|
22
|
+
|
|
23
|
+
export type HookGateId = (typeof HOOK_GATES)[number]['id']
|
|
24
|
+
|
|
25
|
+
/** the page that owns every commit gate */
|
|
26
|
+
export const HOOK_GATES_OWNER = 'build/shipkit/commit-gates.md'
|
|
27
|
+
|
|
28
|
+
/** the whole pre-commit hook, boot included, past which it warns */
|
|
29
|
+
export const HOOK_BUDGET_MS = 1_000
|
|
30
|
+
|
|
31
|
+
/** the repo turned this gate off, and why. commit-language is the French half
|
|
32
|
+
* of commit-message, so turning commit-message off turns it off too */
|
|
33
|
+
export function hookGateOff(c: RepoConfig, id: HookGateId): RepoOff | null {
|
|
34
|
+
const own = repoOff(c, id)
|
|
35
|
+
if (own != null || id !== 'commit-language') return own
|
|
36
|
+
const parent = repoOff(c, 'commit-message')
|
|
37
|
+
return parent == null ? null : { ...parent, reason: `commit-message is off: ${parent.reason}` }
|
|
38
|
+
}
|
package/src/hooks/leak-guard.ts
CHANGED
|
@@ -285,6 +285,8 @@ export type LeakGuardOpts = {
|
|
|
285
285
|
allow?: LeakAllowEntry[]
|
|
286
286
|
/** override the machine-global allowlist path; null reads none */
|
|
287
287
|
allowFile?: string | null
|
|
288
|
+
/** the staged paths, when the caller already listed them (hooks/staged.ts) */
|
|
289
|
+
stagedNames?: string[]
|
|
288
290
|
}
|
|
289
291
|
|
|
290
292
|
/** git c-quotes a path that carries a space or a non-ascii byte. */
|
|
@@ -368,13 +370,16 @@ export function runLeakGuard(root: string, o: LeakGuardOpts = {}): LeakGuardResu
|
|
|
368
370
|
}
|
|
369
371
|
|
|
370
372
|
const targets: ScanTarget[] = []
|
|
371
|
-
if (o.staged !== false) {
|
|
373
|
+
if (o.staged !== false && o.stagedNames?.length !== 0) {
|
|
372
374
|
const diff = Bun.spawnSync([gitBin(), 'diff', '--cached'], { cwd: root, maxBuffer: 256 * 1024 * 1024 })
|
|
373
375
|
if (diff.exitCode === 0)
|
|
374
376
|
for (const f of addedLinesByFile(diff.stdout.toString())) targets.push({ where: 'staged content', file: f.file, content: f.content })
|
|
375
377
|
// a filename can leak as loudly as a line of code
|
|
376
|
-
const names =
|
|
377
|
-
|
|
378
|
+
const names = o.stagedNames ?? (() => {
|
|
379
|
+
const p = Bun.spawnSync([gitBin(), 'diff', '--cached', '--name-only'], { cwd: root })
|
|
380
|
+
return p.exitCode === 0 ? p.stdout.toString().split('\n') : []
|
|
381
|
+
})()
|
|
382
|
+
for (const n of names) if (n.trim() !== '') targets.push({ where: 'staged path', file: n, content: n })
|
|
378
383
|
}
|
|
379
384
|
for (const t of o.text ?? []) targets.push({ where: t.label, content: t.content })
|
|
380
385
|
|
|
@@ -4,14 +4,15 @@
|
|
|
4
4
|
// type instead: `permissions.deny` already blocks these, but a bare deny tells
|
|
5
5
|
// the model only that it was denied, and a refusal with no replacement just
|
|
6
6
|
// sends it looking for another way in. It also refuses a gate piped into
|
|
7
|
-
// another command, whose exit code the pipe hides
|
|
7
|
+
// another command, whose exit code the pipe hides, and a `git stash` in the
|
|
8
|
+
// forms the deny row cannot see.
|
|
8
9
|
//
|
|
9
10
|
// It runs before EVERY Bash call, so it stays a leaf: one tiny import, one
|
|
10
11
|
// small json read, no clipanion, no repo config. Anything it cannot decide it
|
|
11
12
|
// waves through, because a guard that fails closed on its own bugs would wedge
|
|
12
13
|
// a repo with no way to tell why, and `permissions.deny` is still the floor
|
|
13
14
|
// underneath it. Measured at ~10ms per call.
|
|
14
|
-
import { deniedHeads, pipedGateIn, pipedGateRefusal, rawRunnerHit, refusal } from '../core/one-way-in-policy.ts'
|
|
15
|
+
import { deniedHeads, GIT_STASH_HEAD, pipedGateIn, pipedGateRefusal, rawRunnerHit, refusal, stashCallIn, stashRefusal } from '../core/one-way-in-policy.ts'
|
|
15
16
|
|
|
16
17
|
const input = await Bun.stdin.text()
|
|
17
18
|
let command = ''
|
|
@@ -38,6 +39,11 @@ try {
|
|
|
38
39
|
} catch {
|
|
39
40
|
process.exit(0)
|
|
40
41
|
}
|
|
42
|
+
const stash = command !== '' && denied.has(GIT_STASH_HEAD) ? stashCallIn(command) : null
|
|
43
|
+
if (stash != null) {
|
|
44
|
+
console.error(stashRefusal(stash))
|
|
45
|
+
process.exit(2)
|
|
46
|
+
}
|
|
41
47
|
const hit = command === '' || denied.size === 0 ? null : rawRunnerHit(command, denied)
|
|
42
48
|
if (hit == null) process.exit(0)
|
|
43
49
|
// exit 2 is the block: stderr becomes the reason the model reads
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { existsSync } from 'fs'
|
|
9
9
|
import { join } from 'path'
|
|
10
10
|
import { gitBin } from '../core/git-bin.ts'
|
|
11
|
+
import { readStaged, type Staged } from './staged.ts'
|
|
11
12
|
|
|
12
13
|
export type IdentityOutcome =
|
|
13
14
|
| { status: 'skipped'; reason: string }
|
|
@@ -47,19 +48,21 @@ function identityDrift(root: string, path: string, read: (s: string) => string |
|
|
|
47
48
|
return `${path}: name '${before}' → '${after}'`
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
export function runRootIdentity(root: string): IdentityOutcome {
|
|
51
|
-
if (
|
|
51
|
+
export function runRootIdentity(root: string, staged: Staged = readStaged(root)): IdentityOutcome {
|
|
52
|
+
if (!staged.head)
|
|
52
53
|
return { status: 'skipped', reason: 'no HEAD yet — nothing to compare the first commit against' }
|
|
53
54
|
if (process.env['SHIPKIT_ALLOW_IDENTITY_CHANGE'] === '1')
|
|
54
55
|
return { status: 'skipped', reason: 'SHIPKIT_ALLOW_IDENTITY_CHANGE=1' }
|
|
55
56
|
|
|
57
|
+
// a file the commit does not stage is the same blob in the index and in
|
|
58
|
+
// HEAD, so only a staged one is worth reading
|
|
59
|
+
const touched = new Set(staged.names)
|
|
56
60
|
const changes = [
|
|
57
|
-
identityDrift(root, 'package.json', packageName),
|
|
58
|
-
|
|
61
|
+
touched.has('package.json') ? identityDrift(root, 'package.json', packageName) : null,
|
|
62
|
+
touched.has('repo.config.ts') && existsSync(join(root, 'repo.config.ts')) ? identityDrift(root, 'repo.config.ts', configName) : null,
|
|
59
63
|
].filter((c): c is string => c != null)
|
|
60
64
|
|
|
61
|
-
const
|
|
62
|
-
const scratch = added.out.split('\n').map((l) => l.trim()).filter((l) => l !== '' && SCRATCH.test(l))
|
|
65
|
+
const scratch = staged.entries.filter((e) => e.status === 'A' && SCRATCH.test(e.path)).map((e) => e.path)
|
|
63
66
|
|
|
64
67
|
return changes.length === 0 && scratch.length === 0 ? { status: 'clean' } : { status: 'changed', changes, scratch }
|
|
65
68
|
}
|
package/src/hooks/run-hooks.ts
CHANGED
|
@@ -4,20 +4,24 @@
|
|
|
4
4
|
// while its next commit is already doomed, and nothing can enumerate what
|
|
5
5
|
// the hooks enforce. `shipkit hooks list` answers that without reading shell.
|
|
6
6
|
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'fs'
|
|
7
|
-
import { dirname, join } from 'path'
|
|
7
|
+
import { dirname, join, relative } from 'path'
|
|
8
8
|
import { fileURLToPath } from 'url'
|
|
9
9
|
import { KEYWORD_FILE, LAYOUT } from '../core/layout.ts'
|
|
10
10
|
import { commitMessageOffenses } from './commit-message.ts'
|
|
11
11
|
import { globalAllowFile, globalKeywordFile, maskPattern, patternFingerprint, runLeakGuard, SK_KEY_ROW, type LeakAllowEntry, type LeakHit, type LeakWaiver } from './leak-guard.ts'
|
|
12
12
|
import { runRootIdentity } from './root-identity.ts'
|
|
13
|
+
import { readStaged } from './staged.ts'
|
|
14
|
+
import { ALLOW_MASS_DELETE, runTreeShrink } from './tree-shrink.ts'
|
|
13
15
|
import { gitRoot } from '../core/git-root.ts'
|
|
14
16
|
import { spawnWithTimeout } from '../core/spawn-timeout.ts'
|
|
15
17
|
import type { RepoConfig } from '../repo-config.ts'
|
|
16
18
|
import { gitBin } from '../core/git-bin.ts'
|
|
17
|
-
import {
|
|
19
|
+
import { discoverPreCommit, SLOW_IMPORT_MS } from '../core/pre-commit-discovery.ts'
|
|
18
20
|
import { CheckCtx } from '../core/check-ctx.ts'
|
|
21
|
+
import { CheckPass } from '../core/check-pass.ts'
|
|
19
22
|
import { runCheckBody, type CheckDef } from '../check.ts'
|
|
20
23
|
import { repoOff } from '../core/when.ts'
|
|
24
|
+
import { HOOK_BUDGET_MS, hookGateOff, type HookGateId } from './hook-gates.ts'
|
|
21
25
|
import { formatLine } from '../core/wire.ts'
|
|
22
26
|
|
|
23
27
|
export type HooksConfig = {
|
|
@@ -56,11 +60,11 @@ export type HooksConfig = {
|
|
|
56
60
|
/** words the commit-message language gate never counts as French
|
|
57
61
|
* (proper nouns that carry accents without being a language choice) */
|
|
58
62
|
languageAllow?: string[]
|
|
59
|
-
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
disable?:
|
|
63
|
+
/** RETIRED, read as `off` while SK029 ports it there: a commit gate is
|
|
64
|
+
* turned off like any check, `off: { <gate id>: '<why>' }`
|
|
65
|
+
* (build/shipkit/runs.md). Dies once `shipkit fleet` shows no repo
|
|
66
|
+
* carrying it */
|
|
67
|
+
disable?: HookGateId[]
|
|
64
68
|
}
|
|
65
69
|
|
|
66
70
|
const HOOK_DIR = LAYOUT.hooks
|
|
@@ -220,9 +224,26 @@ const draftsOpen = (c: RepoConfig): boolean => c.visibility === 'private'
|
|
|
220
224
|
export async function runPreCommit(root: string, config: RepoConfig): Promise<GateOutcome[]> {
|
|
221
225
|
const started = performance.now()
|
|
222
226
|
const h = config.hooks ?? {}
|
|
227
|
+
const off = (id: HookGateId): boolean => hookGateOff(config, id) != null
|
|
223
228
|
const out: GateOutcome[] = []
|
|
224
|
-
|
|
225
|
-
|
|
229
|
+
const staged = readStaged(root)
|
|
230
|
+
if (!off('tree-shrink')) {
|
|
231
|
+
const r = runTreeShrink(root, staged)
|
|
232
|
+
if (r.status === 'skipped') out.push({ id: 'tree-shrink', ok: true, lines: [`skip: tree-shrink (${r.reason})`] })
|
|
233
|
+
else if (r.status === 'clean') out.push({ id: 'tree-shrink', ok: true, lines: ['ok: tree-shrink, the commit keeps the tree'] })
|
|
234
|
+
else if (r.status === 'shrunk') out.push({
|
|
235
|
+
id: 'tree-shrink',
|
|
236
|
+
ok: false,
|
|
237
|
+
lines: [
|
|
238
|
+
`FAIL: this commit keeps ${r.stagedFiles} of HEAD's ${r.headFiles} files (${r.deleted} deleted), under half the tree`,
|
|
239
|
+
` a commit from a private GIT_INDEX_FILE that was never filled with \`git read-tree HEAD\` deletes every file it did not stage`,
|
|
240
|
+
` fix: commit from the real index by pathspec (git commit -m … -- <paths>), or fill the private index first`,
|
|
241
|
+
` a deliberate mass delete: ${ALLOW_MASS_DELETE}=1 git commit ...`,
|
|
242
|
+
],
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
if (!off('leak-guard')) {
|
|
246
|
+
const r = runLeakGuard(root, { keywordFile: h.keywordFile, allow: h.leakAllow, stagedNames: staged.names })
|
|
226
247
|
if (r.status === 'skipped') out.push({ id: 'leak-guard', ok: true, lines: [`skip: leak-guard (${r.reason})`] })
|
|
227
248
|
else if (r.status === 'clean') out.push({
|
|
228
249
|
id: 'leak-guard',
|
|
@@ -246,10 +267,8 @@ export async function runPreCommit(root: string, config: RepoConfig): Promise<Ga
|
|
|
246
267
|
],
|
|
247
268
|
})
|
|
248
269
|
}
|
|
249
|
-
if ((h.blockedPaths ?? []).length > 0) {
|
|
250
|
-
const
|
|
251
|
-
const staged = names.stdout.toString().split('\n').filter(Boolean)
|
|
252
|
-
const blocked = blockedStaged(staged, config)
|
|
270
|
+
if ((h.blockedPaths ?? []).length > 0 && !off('blocked-paths')) {
|
|
271
|
+
const blocked = blockedStaged(staged.names, config)
|
|
253
272
|
out.push(blocked.length === 0
|
|
254
273
|
? { id: 'blocked-paths', ok: true, lines: [`ok: blocked-paths clean (${h.blockedPaths!.join(', ')})`] }
|
|
255
274
|
: {
|
|
@@ -262,8 +281,8 @@ export async function runPreCommit(root: string, config: RepoConfig): Promise<Ga
|
|
|
262
281
|
],
|
|
263
282
|
})
|
|
264
283
|
}
|
|
265
|
-
if (!(
|
|
266
|
-
const r = runRootIdentity(root)
|
|
284
|
+
if (!off('root-identity')) {
|
|
285
|
+
const r = runRootIdentity(root, staged)
|
|
267
286
|
if (r.status === 'skipped') out.push({ id: 'root-identity', ok: true, lines: [`skip: root-identity (${r.reason})`] })
|
|
268
287
|
else if (r.status === 'clean') out.push({ id: 'root-identity', ok: true, lines: ['ok: root-identity unchanged'] })
|
|
269
288
|
else out.push({
|
|
@@ -297,15 +316,19 @@ export async function runPreCommit(root: string, config: RepoConfig): Promise<Ga
|
|
|
297
316
|
return out
|
|
298
317
|
}
|
|
299
318
|
|
|
300
|
-
|
|
301
|
-
export const HOOK_BUDGET_MS = 1_000
|
|
319
|
+
export { HOOK_BUDGET_MS }
|
|
302
320
|
|
|
303
321
|
/** the checks that declare `onPreCommit` and apply here, with their budget */
|
|
304
322
|
export type PreCommitCheck = { id: string; title: string; owner: string; budgetMs: number; run: NonNullable<CheckDef['run']>; repair: unknown }
|
|
305
323
|
|
|
306
324
|
export async function preCommitChecks(root: string, config: RepoConfig): Promise<PreCommitCheck[]> {
|
|
325
|
+
return (await preCommitDiscovery(root, config)).checks
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function preCommitDiscovery(root: string, config: RepoConfig): Promise<{ checks: PreCommitCheck[]; slow: { path: string; ms: number }[] }> {
|
|
329
|
+
const found = await discoverPreCommit(root)
|
|
307
330
|
const out: PreCommitCheck[] = []
|
|
308
|
-
for (const u of
|
|
331
|
+
for (const u of found.checks) {
|
|
309
332
|
const p = u.def.runs.onPreCommit
|
|
310
333
|
if (p === false || u.def.run == null) continue
|
|
311
334
|
if (repoOff(config, u.id) != null) continue
|
|
@@ -314,16 +337,25 @@ export async function preCommitChecks(root: string, config: RepoConfig): Promise
|
|
|
314
337
|
}
|
|
315
338
|
out.push({ id: u.id, title: u.title, owner: u.owner, budgetMs: p.budgetMs, run: u.def.run, repair: u.def.repair })
|
|
316
339
|
}
|
|
317
|
-
return out
|
|
340
|
+
return { checks: out, slow: found.slow }
|
|
318
341
|
}
|
|
319
342
|
|
|
320
343
|
/** every `onPreCommit` check, run RAW on the working tree: no ledger, no
|
|
321
344
|
* cache, no repair. A failure refuses the commit, a pass over its budget
|
|
322
|
-
* warns by name and never blocks on time
|
|
345
|
+
* warns by name and never blocks on time. One pass for all of them, and one
|
|
346
|
+
* that records no config reads, since nothing stores these answers */
|
|
323
347
|
async function runPreCommitChecks(root: string, config: RepoConfig): Promise<GateOutcome[]> {
|
|
324
348
|
const out: GateOutcome[] = []
|
|
325
|
-
|
|
326
|
-
|
|
349
|
+
const found = await preCommitDiscovery(root, config)
|
|
350
|
+
for (const s of found.slow)
|
|
351
|
+
out.push({
|
|
352
|
+
id: 'slow-import',
|
|
353
|
+
ok: true,
|
|
354
|
+
lines: [`WARN ${relative(root, s.path)} took ${Math.round(s.ms)}ms to import, over ${SLOW_IMPORT_MS}ms: every surface that lists the checks pays it, so move its heavy imports inside \`run\` (build/shipkit/writing-a-check.md)`],
|
|
355
|
+
})
|
|
356
|
+
const pass = new CheckPass(root, config, undefined, false)
|
|
357
|
+
for (const k of found.checks) {
|
|
358
|
+
const c = new CheckCtx(pass)
|
|
327
359
|
const t = performance.now()
|
|
328
360
|
await runCheckBody({ id: k.id, title: k.title, owner: k.owner, run: k.run, fix: k.repair }, c)
|
|
329
361
|
const ms = performance.now() - t
|
|
@@ -339,12 +371,12 @@ export async function runCommitMsg(root: string, config: RepoConfig, messageFile
|
|
|
339
371
|
const h = config.hooks ?? {}
|
|
340
372
|
const out: GateOutcome[] = []
|
|
341
373
|
const message = readFileSync(messageFile, 'utf8')
|
|
342
|
-
if (
|
|
343
|
-
const languageOff = (
|
|
374
|
+
if (hookGateOff(config, 'commit-message') == null) {
|
|
375
|
+
const languageOff = hookGateOff(config, 'commit-language') != null
|
|
344
376
|
const offenses = commitMessageOffenses(message, { languageAllow: h.languageAllow })
|
|
345
377
|
.filter((o) => o.kind !== 'language' || !languageOff)
|
|
346
378
|
out.push(offenses.length === 0
|
|
347
|
-
? { id: 'commit-message', ok: true, lines: [`ok: commit message clean (no dash punctuation
|
|
379
|
+
? { id: 'commit-message', ok: true, lines: [`ok: commit message clean (no dash punctuation${languageOff ? '' : ', English'})`] }
|
|
348
380
|
: {
|
|
349
381
|
id: 'commit-message',
|
|
350
382
|
ok: false,
|
|
@@ -353,17 +385,10 @@ export async function runCommitMsg(root: string, config: RepoConfig, messageFile
|
|
|
353
385
|
...offenses.map((o) => ` line ${o.line}: ${o.text.slice(0, 100)}\n ${o.why}`),
|
|
354
386
|
],
|
|
355
387
|
})
|
|
356
|
-
} else {
|
|
357
|
-
// AI attribution is the one message rule no config lifts: this hook is
|
|
358
|
-
// the guard against it in every repo (identity/how-we-commit.md)
|
|
359
|
-
const offenses = commitMessageOffenses(message).filter((o) => o.kind === 'attribution')
|
|
360
|
-
out.push(offenses.length === 0
|
|
361
|
-
? { id: 'ai-attribution', ok: true, lines: ['ok: no AI attribution in the commit message'] }
|
|
362
|
-
: { id: 'ai-attribution', ok: false, lines: [`FAIL: commit message carries AI attribution`, ...offenses.map((o) => ` line ${o.line}: ${o.text.slice(0, 100)}\n ${o.why}`)] })
|
|
363
388
|
}
|
|
364
389
|
// the leak guard also reads the MESSAGE, a private name pasted into a
|
|
365
390
|
// commit body is as public as one pasted into a file
|
|
366
|
-
if (
|
|
391
|
+
if (hookGateOff(config, 'leak-guard') == null) {
|
|
367
392
|
const r = runLeakGuard(root, { keywordFile: h.keywordFile, staged: false, allow: h.leakAllow, text: [{ label: 'commit message', content: message }] })
|
|
368
393
|
if (r.status === 'hit')
|
|
369
394
|
out.push({ id: 'leak-guard', ok: false, lines: [`FAIL: leak-guard matched in the commit message`, ...r.hits.map(hitLine), ...waivedLines(r.waived)] })
|
|
@@ -381,27 +406,33 @@ export async function runCommitMsg(root: string, config: RepoConfig, messageFile
|
|
|
381
406
|
return out
|
|
382
407
|
}
|
|
383
408
|
|
|
384
|
-
/** what blocks a commit here, the enumeration hand-rolled hooks never had.
|
|
409
|
+
/** what blocks a commit here, the enumeration hand-rolled hooks never had.
|
|
410
|
+
* A gate the repo turned off stays listed, with the reason `off` gives it */
|
|
385
411
|
export function listGates(config: RepoConfig, root = process.cwd(), checks: { id: string; budgetMs: number }[] = []): string[] {
|
|
386
412
|
const h = config.hooks ?? {}
|
|
387
|
-
const
|
|
413
|
+
const offWord = (id: HookGateId): string => {
|
|
414
|
+
const o = hookGateOff(config, id)
|
|
415
|
+
return o == null ? '' : ` (OFF: ${o.reason})`
|
|
416
|
+
}
|
|
388
417
|
const kw = h.keywordFile ?? KEYWORD_FILE
|
|
389
418
|
// a gate listed as active but unable to fire is worse than no listing:
|
|
390
419
|
// this surface exists so nobody has to guess
|
|
391
|
-
const leakState =
|
|
420
|
+
const leakState = offWord('leak-guard') !== '' ? offWord('leak-guard') : existsSync(join(root, kw)) || globalKeywordFile() != null ? '' : ` (INACTIVE, ${kw} absent and no ~/.shipkit/private list)`
|
|
392
421
|
// a waiver nobody can enumerate is a hole nobody can audit: the count of
|
|
393
422
|
// declared exemptions belongs on the same surface as the gate itself
|
|
394
423
|
const waivers = (h.leakAllow ?? []).length + (globalAllowFile() != null ? 1 : 0)
|
|
395
424
|
const leakAllowState = waivers === 0 ? '' : `, ${(h.leakAllow ?? []).length} repo waiver(s)${globalAllowFile() == null ? '' : ' + ~/.shipkit/private/leak-allow.json'}`
|
|
425
|
+
const messageOff = offWord('commit-message')
|
|
396
426
|
return [
|
|
397
427
|
`pre-commit leak-guard${leakState} — staged added lines + paths vs ${kw} + ~/.shipkit/private/banned-keywords.txt${leakAllowState}`,
|
|
398
|
-
...((h.blockedPaths ?? []).length > 0 ? [`pre-commit blocked-paths — nothing under ${h.blockedPaths!.join(', ')} enters git${draftsOpen(config) && blockedStaged([COMFY_DRAFTS], config).length === 0 && h.blockedPaths!.some((p) => COMFY_DRAFTS.startsWith(p)) ? `, except ${COMFY_DRAFTS} (declared private)` : ''}`] : []),
|
|
399
|
-
`pre-commit root-identity${
|
|
428
|
+
...((h.blockedPaths ?? []).length > 0 ? [`pre-commit blocked-paths${offWord('blocked-paths')} — nothing under ${h.blockedPaths!.join(', ')} enters git${draftsOpen(config) && blockedStaged([COMFY_DRAFTS], config).length === 0 && h.blockedPaths!.some((p) => COMFY_DRAFTS.startsWith(p)) ? `, except ${COMFY_DRAFTS} (declared private)` : ''}`] : []),
|
|
429
|
+
`pre-commit root-identity${offWord('root-identity')} — the repo's own name in package.json + repo.config.ts, and scratch files`,
|
|
430
|
+
`pre-commit tree-shrink${offWord('tree-shrink')} — the commit keeps at least half of HEAD's files (${ALLOW_MASS_DELETE}=1 lifts it for one commit)`,
|
|
400
431
|
...checks.map((c) => `pre-commit ${c.id} — the check, onPreCommit, budget ${c.budgetMs}ms`),
|
|
401
432
|
...(h.preCommit ?? []).map((c) => `pre-commit ${c.id} — ${c.cmd.join(' ')}`),
|
|
402
|
-
|
|
403
|
-
? `commit-msg commit-message
|
|
404
|
-
: `commit-msg commit-message — dash punctuation +
|
|
433
|
+
messageOff !== ''
|
|
434
|
+
? `commit-msg commit-message${messageOff} — dash punctuation + French`
|
|
435
|
+
: `commit-msg commit-message — dash punctuation + French${offWord('commit-language') === '' ? '' : ` (commit-language${offWord('commit-language')})`}`,
|
|
405
436
|
`commit-msg leak-guard${leakState} — the message text`,
|
|
406
437
|
...(h.commitMsg ?? []).map((c) => `commit-msg ${c.id} — ${c.cmd.join(' ')}`),
|
|
407
438
|
]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// what this commit changes against HEAD, listed ONCE for every git based
|
|
2
|
+
// step of the pre-commit gate: each step spawning its own `git diff --cached`
|
|
3
|
+
// costs a process start per step on every commit. Every call inherits the
|
|
4
|
+
// environment, so a commit made under a private GIT_INDEX_FILE is read from
|
|
5
|
+
// that same index, which is the one git is about to commit.
|
|
6
|
+
import { gitBin } from '../core/git-bin.ts'
|
|
7
|
+
import { gitRoot } from '../core/git-root.ts'
|
|
8
|
+
|
|
9
|
+
/** one staged change: git's status letter (A, M, D, R, C, T, U), the path as
|
|
10
|
+
* `--name-only` prints it, and for a rename or a copy the path it came from */
|
|
11
|
+
export type StagedEntry = { status: string; path: string; from?: string }
|
|
12
|
+
|
|
13
|
+
export type Staged = {
|
|
14
|
+
/** HEAD resolves: false on the first commit of a repo */
|
|
15
|
+
head: boolean
|
|
16
|
+
entries: StagedEntry[]
|
|
17
|
+
/** every staged path, in git's order */
|
|
18
|
+
names: string[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** `git diff --cached --name-status -z`: a status field, then one path, or
|
|
22
|
+
* two for a rename or a copy. NUL separated, so no path is ever quoted */
|
|
23
|
+
export function parseNameStatus(out: string): StagedEntry[] {
|
|
24
|
+
const f = out.split('\0')
|
|
25
|
+
const entries: StagedEntry[] = []
|
|
26
|
+
for (let i = 0; i < f.length; ) {
|
|
27
|
+
const status = f[i++] ?? ''
|
|
28
|
+
if (status === '') break
|
|
29
|
+
const letter = status[0]!
|
|
30
|
+
if (letter === 'R' || letter === 'C') {
|
|
31
|
+
const from = f[i++] ?? ''
|
|
32
|
+
entries.push({ status: letter, path: f[i++] ?? '', from })
|
|
33
|
+
} else entries.push({ status: letter, path: f[i++] ?? '' })
|
|
34
|
+
}
|
|
35
|
+
return entries
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function readStaged(root: string): Staged {
|
|
39
|
+
if (gitRoot(root) == null) return { head: false, entries: [], names: [] }
|
|
40
|
+
// the env is passed on purpose: Bun's default is the environment the
|
|
41
|
+
// process started with, never one a caller set since
|
|
42
|
+
const head = Bun.spawnSync([gitBin(), 'rev-parse', '--verify', '-q', 'HEAD'], { cwd: root, env: process.env, stdout: 'ignore', stderr: 'ignore' }).exitCode === 0
|
|
43
|
+
const p = Bun.spawnSync([gitBin(), 'diff', '--cached', '--name-status', '-z'], { cwd: root, env: process.env, maxBuffer: 64 * 1024 * 1024 })
|
|
44
|
+
// an unreadable index is no answer, and every gate below would read it as
|
|
45
|
+
// "nothing staged" and pass
|
|
46
|
+
if (p.exitCode !== 0) throw new Error(`git diff --cached exited ${p.exitCode}: ${p.stderr.toString().trim().split('\n')[0] ?? ''}`)
|
|
47
|
+
const entries = parseNameStatus(p.stdout.toString())
|
|
48
|
+
return { head, entries, names: entries.map((e) => e.path) }
|
|
49
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// a commit must not drop most of the tree.
|
|
2
|
+
//
|
|
3
|
+
// the failure this exists for: a commit made from a private GIT_INDEX_FILE
|
|
4
|
+
// that was never filled with `git read-tree HEAD` holds only the files staged
|
|
5
|
+
// into it, so git records every other file as deleted and the tree comes out
|
|
6
|
+
// near empty. Every other gate passes, because nothing it reads is wrong.
|
|
7
|
+
import type { Staged } from './staged.ts'
|
|
8
|
+
import { gitBin } from '../core/git-bin.ts'
|
|
9
|
+
|
|
10
|
+
export const ALLOW_MASS_DELETE = 'SHIPKIT_ALLOW_MASS_DELETE'
|
|
11
|
+
|
|
12
|
+
export type ShrinkOutcome =
|
|
13
|
+
| { status: 'skipped'; reason: string }
|
|
14
|
+
| { status: 'clean' }
|
|
15
|
+
| { status: 'shrunk'; headFiles: number; stagedFiles: number; deleted: number }
|
|
16
|
+
|
|
17
|
+
/** the decision, with no git in it. `headFiles` is asked for only when the
|
|
18
|
+
* commit removes more files than it adds, so an ordinary commit pays nothing */
|
|
19
|
+
export function shrinkVerdict(staged: Staged, headFiles: () => number): ShrinkOutcome {
|
|
20
|
+
if (!staged.head) return { status: 'skipped', reason: 'no HEAD yet, nothing to shrink' }
|
|
21
|
+
let deleted = 0
|
|
22
|
+
let added = 0
|
|
23
|
+
for (const e of staged.entries) {
|
|
24
|
+
if (e.status === 'D') deleted++
|
|
25
|
+
else if (e.status === 'A' || e.status === 'C') added++
|
|
26
|
+
}
|
|
27
|
+
if (deleted <= added) return { status: 'clean' }
|
|
28
|
+
const before = headFiles()
|
|
29
|
+
const after = before - deleted + added
|
|
30
|
+
if (after * 2 >= before) return { status: 'clean' }
|
|
31
|
+
if (process.env[ALLOW_MASS_DELETE] === '1') return { status: 'skipped', reason: `${ALLOW_MASS_DELETE}=1` }
|
|
32
|
+
return { status: 'shrunk', headFiles: before, stagedFiles: after, deleted }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function headFileCount(root: string): number {
|
|
36
|
+
const p = Bun.spawnSync([gitBin(), 'ls-tree', '-r', '-z', '--name-only', '--full-tree', 'HEAD'], { cwd: root, env: process.env, maxBuffer: 256 * 1024 * 1024 })
|
|
37
|
+
if (p.exitCode !== 0) throw new Error(`git ls-tree HEAD exited ${p.exitCode}: ${p.stderr.toString().trim().split('\n')[0] ?? ''}`)
|
|
38
|
+
return p.stdout.toString().split('\0').filter(Boolean).length
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const runTreeShrink = (root: string, staged: Staged): ShrinkOutcome => shrinkVerdict(staged, () => headFileCount(root))
|
package/src/init-repo.ts
CHANGED
|
@@ -17,9 +17,6 @@ import { reportToMotherhome } from './core/motherhome.ts'
|
|
|
17
17
|
import { ensureGitRepo, publishRepo, type Run } from './git-bootstrap.ts'
|
|
18
18
|
import { defineRepo, loadRepoConfig, resolveFloor } from './repo-config.ts'
|
|
19
19
|
import { gitignorePolicy } from './checks/SK004-gitignore-policy.check.ts'
|
|
20
|
-
import { vscodeHubTask } from './checks/SK017-vscode-hub-task.check.ts'
|
|
21
|
-
import { editorErgonomics } from './scaffold/project-scaffold.ts'
|
|
22
|
-
import { defaultHubPort } from './serve/hub-port.ts'
|
|
23
20
|
|
|
24
21
|
// This lands as SOURCE in the repo being wired, so its comments answer to that
|
|
25
22
|
// repo's comment style, not to this file's. Dash punctuation is banned there
|
|
@@ -105,7 +102,6 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
105
102
|
// the repos that need ONE WAY IN most are the ones that already exist,
|
|
106
103
|
// so a re-run REPAIRS rather than only seeding a fresh checkout
|
|
107
104
|
await repairOneWayIn(root, cfg, p)
|
|
108
|
-
await furnishEditor(root, cfg, p)
|
|
109
105
|
const mh = reportToMotherhome(root, cfg)
|
|
110
106
|
if (mh.note != null) p(mh.note)
|
|
111
107
|
if (wired && opts.noPublish !== true) await publishRepo(root, { github: opts.github, gh: opts.gh, print: p })
|
|
@@ -139,7 +135,6 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
139
135
|
const g = await gitignorePolicy.repair!(CheckCtx.open({ root, config: defineRepo({ name }) }))
|
|
140
136
|
p(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
|
|
141
137
|
await repairOneWayIn(root, defineRepo({ name }), p)
|
|
142
|
-
await furnishEditor(root, { name }, p)
|
|
143
138
|
// what the gate actually runs, in ONE line: a thin floor must be seen at
|
|
144
139
|
// init time rather than discovered after a bad commit slipped through.
|
|
145
140
|
// The per-task resolution is diagnostics, and a `skip:` naming a script
|
|
@@ -170,43 +165,6 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
170
165
|
return 0
|
|
171
166
|
}
|
|
172
167
|
|
|
173
|
-
/** the folder-open story, on the adopt path as well as the start one.
|
|
174
|
-
*
|
|
175
|
-
* `start` scaffolds the whole day-one shape, but only for a dir with no
|
|
176
|
-
* commit history, so every repo that already existed when it was wired got
|
|
177
|
-
* repo.config.ts and the scripts and no .vscode/ at all: opening the folder
|
|
178
|
-
* started nothing, and the hub the config advertises had no way in. These two
|
|
179
|
-
* files are the only half of that scaffold an adopted repo can be handed
|
|
180
|
-
* unasked, because they change no output of any command (project-scaffold.ts
|
|
181
|
-
* owns the line). Create-only, so a .vscode/ the repo brought with it wins. */
|
|
182
|
-
async function furnishEditor(root: string, cfg: { name?: string; serve?: { port?: number } }, p: (line: string) => void): Promise<void> {
|
|
183
|
-
// the least important thing init does, and it runs AFTER repo.config.ts is
|
|
184
|
-
// on disk: an unwritable .vscode (a read-only tree, a path that is a file),
|
|
185
|
-
// or a config with no name at all, must not abort the run before the
|
|
186
|
-
// publish chain, and must not pass in silence either. The port resolution
|
|
187
|
-
// is INSIDE the try for that reason: it reads a config nothing validates
|
|
188
|
-
try {
|
|
189
|
-
const name = repoName(root, undefined, cfg.name)
|
|
190
|
-
const port = cfg.serve?.port ?? defaultHubPort(name)
|
|
191
|
-
if (editorErgonomics(root, { name, port }).length === 0) {
|
|
192
|
-
// the repo's own file stays its own, except the hub task, which
|
|
193
|
-
// rule SK017 keeps feeding the problems pane
|
|
194
|
-
// a file SK017 cannot read is still the repo's own: it is never
|
|
195
|
-
// reported as missing, and it is never rewritten
|
|
196
|
-
let hub: { changed: boolean; summary: string }
|
|
197
|
-
try { hub = await vscodeHubTask.repair!(CheckCtx.open({ root, config: defineRepo({ name }) })) } catch (e) {
|
|
198
|
-
p('ok: .vscode/ is this repo\'s own, left untouched')
|
|
199
|
-
return void p(`WARN .vscode/tasks.json does not parse (${e instanceof Error ? e.message : String(e)}): the hub task gets no problem matcher until it does`)
|
|
200
|
-
}
|
|
201
|
-
return void p(hub.changed ? `ok: ${hub.summary}` : 'ok: .vscode/ is this repo\'s own, left untouched')
|
|
202
|
-
}
|
|
203
|
-
p(`ok: .vscode/tasks.json written — opening the folder starts the hub on ${port}`)
|
|
204
|
-
p(' next: add this stack\'s own watchers (dev server, css) — build/vscode-tasks.md carries the shapes')
|
|
205
|
-
} catch (e) {
|
|
206
|
-
p(`WARN no .vscode/tasks.json here: ${e instanceof Error ? e.message : String(e)} — opening the folder starts nothing, everything else landed`)
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
168
|
/** ONE WAY IN, both halves, through the SAME fixers `shipkit check --fix` runs
|
|
211
169
|
* (build/shipkit/one-way-in.md). Nothing here is a second implementation: rule SK001
|
|
212
170
|
* routes the package.json scripts, rule SK013 writes the harness policy, and
|
|
@@ -87,7 +87,7 @@ export function renderCheckTable(input: CheckTableInput): string {
|
|
|
87
87
|
body.push('')
|
|
88
88
|
body.push(`Everything \`shipkit ci\` and \`shipkit check\` know how to run: ${builds} build step(s) the gate alone runs, ${rows.length - builds} the health pass reports too. ${shipped} ship with the toolkit, ${rows.length - shipped} belong to this repo.`)
|
|
89
89
|
body.push('')
|
|
90
|
-
body.push('- **runs in** — `always` means every `shipkit ci` runs it, so a red one says THIS change is broken. `release` means the quick gate leaves it out and `shipkit ci --full` plus the release path run it. `health` means only the health pass (`shipkit check`) runs it: it reports the world (a box that is off, a cred nobody minted, a page nobody refreshed), it can sit red for months with nothing wrong, and it never stands between you and a commit. `off` means this repo turned it off on purpose, and the reason is in `repo.config.ts
|
|
90
|
+
body.push('- **runs in** — `always` means every `shipkit ci` runs it, so a red one says THIS change is broken. `release` means the quick gate leaves it out and `shipkit ci --full` plus the release path run it. `health` means only the health pass (`shipkit check`) runs it: it reports the world (a box that is off, a cred nobody minted, a page nobody refreshed), it can sit red for months with nothing wrong, and it never stands between you and a commit. `commit` means a git hook runs it on every commit (`shipkit hooks list`). `off` means this repo turned it off on purpose, and the reason is in `repo.config.ts off`.')
|
|
91
91
|
body.push('- **build** — `yes` means the gate alone runs it: it BUILDS (a compiler, a test suite, a bundler), so `shipkit check` leaves it out and reports everything else. A floor slot derives it from the package.json script behind it, and a `*.check.ts` declares it with `build: true`.')
|
|
92
92
|
body.push('- **on failure** — `blocks` stops the run. `warns` prints and carries on. `UNDECLARED` is a defect, not a setting: the check never said and the repo set no default, so the runner treats it as fatal by guess.')
|
|
93
93
|
body.push('- **cache** — `content` is cached while the files it reads are untouched. `world` is cached for its TTL, and a RED that ran is cached too, capped at one hour so the run after a fix sees the fix. `never` runs every time and somebody wrote down why. `UNDECLARED` runs every time and nobody has looked yet, which is the burn-down (`shipkit keys --burn-down`). `SHIPKIT_FRESH=1` busts all of it.')
|