rvlib-shipkit 1.8.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/package.json +1 -1
  3. package/src/assets/gen/comfyts-gen.ts +31 -14
  4. package/src/assets/gen/generate-roll.ts +20 -7
  5. package/src/assets/kit-config.ts +9 -0
  6. package/src/assets/kits/identity-kit.ts +7 -1
  7. package/src/assets/run-assets.ts +3 -3
  8. package/src/assets/server/studio-server.ts +3 -3
  9. package/src/check.ts +5 -2
  10. package/src/checks/SK001-package-json-shape.check.ts +47 -12
  11. package/src/checks/SK013-one-way-in-policy.check.ts +8 -7
  12. package/src/checks/SK017-vscode-hub-task.check.ts +109 -106
  13. package/src/checks/SK019-tests-run-in-units.check.ts +2 -1
  14. package/src/checks/SK027-agent-wiki-links.check.ts +5 -2
  15. package/src/checks/SK029-explicit-check-files.check.ts +25 -8
  16. package/src/checks/SK034-commit-gates-armed.check.ts +90 -0
  17. package/src/cli/cmd_Ci.ts +6 -5
  18. package/src/cli/cmd_Commands.ts +21 -5
  19. package/src/cli/cmd_Hooks.ts +6 -2
  20. package/src/cli/cmd_Init.ts +1 -1
  21. package/src/cli/cmd_Serve.ts +1 -2
  22. package/src/cli/cmd_Start.ts +1 -1
  23. package/src/cli.ts +2 -4
  24. package/src/collections/providers.ts +1 -1
  25. package/src/core/budget.ts +6 -8
  26. package/src/core/bun-builtins.ts +36 -0
  27. package/src/core/bun-tool-cli.ts +23 -0
  28. package/src/core/canonical-scripts.ts +6 -4
  29. package/src/core/check-ctx.ts +104 -10
  30. package/src/core/check-file-names.ts +2 -3
  31. package/src/core/check-pass.ts +17 -1
  32. package/src/core/check-runner.ts +32 -10
  33. package/src/core/config-rewrite.ts +27 -6
  34. package/src/core/discover-checks.ts +64 -15
  35. package/src/core/fix-scope.ts +5 -0
  36. package/src/core/floor-resolve.ts +18 -8
  37. package/src/core/git-root.ts +22 -12
  38. package/src/core/job-run.ts +2 -2
  39. package/src/core/layout.ts +4 -1
  40. package/src/core/legacy-shapes.ts +40 -0
  41. package/src/core/one-lib.ts +5 -3
  42. package/src/core/one-way-in-policy.ts +106 -13
  43. package/src/core/pre-commit-discovery.ts +106 -0
  44. package/src/core/retired-checks.ts +2 -0
  45. package/src/core/run-ci.ts +23 -16
  46. package/src/core/run-fix.ts +9 -0
  47. package/src/core/run-fixers.ts +91 -35
  48. package/src/core/runs-matrix.ts +7 -0
  49. package/src/core/slots.ts +36 -6
  50. package/src/core/vendored-read.ts +39 -0
  51. package/src/core/vendored.ts +82 -0
  52. package/src/core/waivers.ts +125 -0
  53. package/src/core/when-block.ts +25 -185
  54. package/src/core/when.ts +30 -13
  55. package/src/core/wire.ts +17 -1
  56. package/src/git-bootstrap.ts +1 -1
  57. package/src/home/attach.ts +3 -5
  58. package/src/home/gui-app/ShipkitGui.swift +96 -0
  59. package/src/home/insights.ts +6 -1
  60. package/src/home/pm-config.ts +2 -2
  61. package/src/home/resources.ts +3 -0
  62. package/src/home/run-daemon.ts +2 -2
  63. package/src/home/run-unit-action.ts +2 -2
  64. package/src/hooks/commit-message.ts +2 -14
  65. package/src/hooks/hook-gates.ts +38 -0
  66. package/src/hooks/leak-guard.ts +8 -3
  67. package/src/hooks/one-way-in-guard.ts +8 -2
  68. package/src/hooks/root-identity.ts +9 -6
  69. package/src/hooks/run-hooks.ts +72 -41
  70. package/src/hooks/staged.ts +49 -0
  71. package/src/hooks/tree-shrink.ts +41 -0
  72. package/src/init-repo.ts +0 -42
  73. package/src/ledger/check-table.ts +1 -1
  74. package/src/ledger/gate-checks.ts +45 -9
  75. package/src/ledger/ledger.ts +6 -1
  76. package/src/ledger/repo-view.ts +14 -4
  77. package/src/ledger/toolchain-identity.ts +4 -2
  78. package/src/mv/mv-md.ts +6 -1
  79. package/src/plan/planner.ts +21 -4
  80. package/src/plan/repo-source.ts +7 -3
  81. package/src/problems/extract.ts +27 -5
  82. package/src/problems/problem-line.ts +2 -3
  83. package/src/release/run-release.ts +30 -16
  84. package/src/release/unit-gate.ts +12 -5
  85. package/src/repo-config.ts +51 -27
  86. package/src/scaffold/files/bunfig.toml +11 -0
  87. package/src/scaffold/files/vscode-settings.json +1 -2
  88. package/src/scaffold/jsonc-spans.ts +16 -0
  89. package/src/scaffold/project-scaffold.ts +43 -77
  90. package/src/serve/hub-app/check-view.tsx +4 -4
  91. package/src/serve/hub-app/check-words.ts +1 -0
  92. package/src/serve/hub-app/checks-svc.ts +1 -2
  93. package/src/serve/hub-app/home.html +0 -1
  94. package/src/serve/hub-app/hub-types.ts +0 -2
  95. package/src/serve/hub-app/index.html +0 -1
  96. package/src/serve/hub-app/project-list.css +1 -2
  97. package/src/serve/hub-app/tree-build.ts +3 -2
  98. package/src/serve/hub-app/tree-svc.ts +2 -2
  99. package/src/serve/hub-port.ts +3 -3
  100. package/src/serve/hub-runtime.ts +3 -5
  101. package/src/serve/project-gutter.ts +5 -2
  102. package/src/serve/run-lanes.ts +7 -3
  103. package/src/serve/serve-hub.ts +1 -16
  104. package/src/serve/tracked-watch.ts +1 -1
  105. package/src/smoke/run-smoke.ts +13 -6
  106. package/src/start-repo.ts +15 -3
  107. package/src/suite/judge-suites.ts +81 -32
  108. package/src/suite/select.ts +2 -0
  109. package/src/suite/suite-cli.ts +27 -16
  110. package/src/suite/suite-cpu-preload.ts +7 -11
  111. package/src/suite/test-units.ts +7 -4
  112. package/src/ts/config.ts +12 -5
  113. package/src/ts/imports.ts +3 -1
  114. package/src/ts/typecheck-cache.ts +11 -0
  115. package/src/ts/typecheck-cli.ts +2 -1
  116. package/src/tui/build-steps-panel.ts +1 -1
  117. package/src/wiki/scan-md.ts +16 -9
  118. package/src/checks/SK009-commit-ai-tells.check.ts +0 -131
  119. package/src/checks/SK014-prose-ste.check.ts +0 -78
  120. package/src/cli/cmd_Prose.ts +0 -74
  121. package/src/prose/prose-view.ts +0 -90
  122. package/src/prose/run-prose.ts +0 -187
  123. package/src/prose/ste.ts +0 -552
  124. package/src/scaffold/files/vscode-tasks.json +0 -24
  125. package/src/serve/hub-app/prose-hits-st.ts +0 -125
  126. package/src/serve/hub-app/prose-hits-view.tsx +0 -176
  127. package/src/serve/hub-app/prose-hits.css +0 -21
@@ -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 when`.')
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.')
@@ -26,6 +26,7 @@ import { CHECK_TIMEOUT_MS, type RunnableCheck, type CheckBody } from '../core/ch
26
26
  import { FOREVER, ttlMsOf } from '../core/wire.ts'
27
27
  import { budgetOf } from '../core/budget.ts'
28
28
  import { repoOff, withRepoOff, whenSummary, type WhenDecl } from '../core/when.ts'
29
+ import { HOOK_BUDGET_MS, HOOK_GATES, HOOK_GATES_OWNER, hookGateOff, type HookName } from '../hooks/hook-gates.ts'
29
30
  import { cacheText, filesText, NOWHERE, sourced, type CacheDecl, type ResolvedRuns, type Runs } from '../core/runs.ts'
30
31
  import { isNamesInput, type CheckDecl } from './git-key.ts'
31
32
  import { populationOf } from '../problems/problem-line.ts'
@@ -131,6 +132,9 @@ export type CheckRow = {
131
132
  * `repo` = this repo declared it (a package.json script behind a floor
132
133
  * slot, a task, a rule, a check file, a check file). */
133
134
  from: CheckFrom
135
+ /** a COMMIT GATE: the git hook that runs it (hooks/hook-gates.ts). The
136
+ * runner never does, so every runner context reads false */
137
+ hook?: HookName
134
138
  }
135
139
 
136
140
  /** the one axis every check answers: who wrote the declaration */
@@ -348,15 +352,16 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
348
352
  // time is not judged and its answer never stands in for a quick one
349
353
  out.push({ row, runnable: runnableOf(row, taskBody(t), { unbudgeted: full }) })
350
354
  }
351
- // a slot `defaults: { <id>: false }` turned off never resolves to a task,
352
- // so without this it had no row at all: no `runs in` value, no reason, and
353
- // a count of off checks that read zero while a gate step sat dark. It has
354
- // no command, which is why it declares `never` rather than joining the
355
- // burn-down of checks nobody has keyed yet.
355
+ // a slot the repo turned off never resolves to a task, so without this it
356
+ // had no row at all: no `runs in` value, no reason, and a count of off
357
+ // checks that read zero while a gate step sat dark. It has no command,
358
+ // which is why it declares `never` rather than joining the burn-down of
359
+ // checks nobody has keyed yet.
356
360
  for (const id of floor.disabled) {
357
- const runs = sourced(NOWHERE, 'none', 'repo.config.ts', null, `defaults.${id}: false`)
361
+ const off = repoOff(c, id) ?? { reason: 'turned off', source: 'off' as const }
362
+ const runs = sourced(NOWHERE, 'none', 'repo.config.ts', null, off.reason)
358
363
  const row: CheckRow = {
359
- decl: { id, never: `turned off by defaults.${id}: false, so there is no command to key` },
364
+ decl: { id, never: `the slot is off, so there is no command to key` },
360
365
  title: id,
361
366
  description: slotDescription(id, declaredTestUnits(c.units).length > 0),
362
367
  gate: false,
@@ -369,8 +374,8 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
369
374
  optIn: false,
370
375
  fixable: false,
371
376
  runs,
372
- cache: `never: turned off by defaults.${id}: false`,
373
- when: whenSummary(runs, { reason: `defaults.${id}: false`, source: 'defaults' }, false),
377
+ cache: `never: the slot is off`,
378
+ when: whenSummary(runs, off, false),
374
379
  deps: null,
375
380
  // the repo wrote the line that turned it off
376
381
  from: 'repo',
@@ -518,6 +523,37 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
518
523
  const body: CheckBody = { cmd: ['bun', d.path], ...(task?.env == null ? {} : { env: task.env }), ...(task?.cwd == null ? {} : { cwd: resolve(root, task.cwd) }) }
519
524
  out.push({ row, runnable: runnableOf(row, body, { pointer: `bun ${rel}` }) })
520
525
  }
526
+ // THE COMMIT GATES: the git hooks run them (hooks/run-hooks.ts), never the
527
+ // runner, so a row here only makes them enumerable and `off` able to name
528
+ // them. They run in no runner context, and a run by name says where they run
529
+ for (const g of HOOK_GATES) {
530
+ const na = c.nested === true ? 'n/a here: a nested folder, the parent work tree owns the commit gates'
531
+ : g.id === 'blocked-paths' && (c.hooks?.blockedPaths ?? []).length === 0 ? 'n/a here: no hooks.blockedPaths declared' : null
532
+ const off = hookGateOff(c, g.id)
533
+ const hookWords = g.hook === 'both' ? 'the pre-commit and commit-msg hooks' : `the ${g.hook} hook`
534
+ const runs = withRepoOff(sourced(NOWHERE, 'none', 'derived', `a commit gate: ${hookWords} run it on every commit`, na), off)
535
+ const row: CheckRow = {
536
+ decl: { id: g.id, never: `a commit gate runs on the index at commit, never cached` },
537
+ title: g.title,
538
+ description: g.description,
539
+ gate: false,
540
+ build: false,
541
+ blocking: true,
542
+ owner: HOOK_GATES_OWNER,
543
+ group: null,
544
+ timeoutMs: null,
545
+ budgetMs: HOOK_BUDGET_MS,
546
+ optIn: false,
547
+ fixable: false,
548
+ runs,
549
+ cache: `never: ${hookWords}`,
550
+ when: whenSummary(runs, off, false, { hook: true }),
551
+ deps: null,
552
+ from: 'shipkit',
553
+ hook: g.hook,
554
+ }
555
+ out.push({ row, runnable: runnableOf(row, { run: (ctx) => ctx.note(`${g.id} runs in ${hookWords}, never in a gate: \`shipkit hooks list\` shows it`) }) })
556
+ }
521
557
  return out
522
558
  }
523
559
 
@@ -273,9 +273,14 @@ export class Ledger {
273
273
  /** buffer what a check just measured. Nothing reaches the store until
274
274
  * commit() has re-hashed the inputs (rule 2). A READS check with no read
275
275
  * set (it reached the world) is never stored (rule 7). */
276
- record(id: string, outcome: CheckOutcome, reads?: ReadSet | null, notKept?: string | null): void {
276
+ record(id: string, outcome: CheckOutcome, reads?: ReadSet | null, notKept?: string | null, waiverFiles: readonly string[] = []): void {
277
277
  const k = this.key(id)
278
278
  if (k.key == null) return
279
+ // a READS answer carries every waiving file in its read set. A
280
+ // CONTENT key moves only with its inputs, so a directive outside
281
+ // them could be added or removed under a stored answer
282
+ const outside = k.population === 'content' ? waiverFiles.find((f) => !k.inputOids.some(([p]) => p === '.' || f === p || f.startsWith(`${p}/`))) : undefined
283
+ if (outside != null) { this.ranWhy.set(id, { group: 'not kept', why: `a site in ${outside}, which an in-file directive can waive, sits outside its declared inputs` }); return }
279
284
  if (k.population === 'reads') {
280
285
  if (reads == null) { this.ranWhy.set(id, { group: 'not kept', why: notKept ?? 'it read outside what the cache can see' }); return }
281
286
  this.buffered.set(id, { ...outcome, reads, components: k.components })
@@ -7,8 +7,14 @@
7
7
  // Built once per run and shared: `c.list` in every check and the ledger's
8
8
  // replay check read the same snapshot, so a check never lists one tree and is
9
9
  // validated against another inside one run.
10
+ //
11
+ // A listing leaves out the repo's vendored dirs (core/vendored.ts), unless
12
+ // its glob names a path inside one: a check never mistakes third party code
13
+ // for the repo's own, and the replay check lists through the same filter.
10
14
  import { relative } from 'path'
11
15
  import { gitBin } from '../core/git-bin.ts'
16
+ import { listingWithoutVendored } from '../core/vendored.ts'
17
+ import { readVendored } from '../core/vendored-read.ts'
12
18
  import { GitScanError, scanPaths, WHOLE_TREE, type ScanResult } from './git-scan.ts'
13
19
 
14
20
  export class RepoView {
@@ -21,26 +27,30 @@ export class RepoView {
21
27
  readonly fallback: string | null,
22
28
  /** the scan it was read off, handed to the next open as its prior */
23
29
  readonly scan: ScanResult | null = null,
30
+ /** the repo's vendored dirs, as its config text declared them at open */
31
+ readonly vendored: readonly string[] = [],
24
32
  ) {}
25
33
 
26
34
  /** `prior`: an earlier view of the same root in this run. The tree is
27
35
  * still staged and hashed fresh, so the list is today's, and an unchanged
28
36
  * tree reuses the prior's answer */
29
37
  static open(root: string, prior?: RepoView | null): RepoView {
38
+ const vendored = readVendored(root)
30
39
  try {
31
40
  const scan = scanPaths(root, [WHOLE_TREE], prior?.scan ?? undefined, { treeOnly: true })
32
41
  const tree = scan.oidByPath.get(WHOLE_TREE)
33
42
  if (tree == null) throw new GitScanError('git could not name the work tree')
34
- return new RepoView(filesOf(root, tree), null, scan)
43
+ return new RepoView(filesOf(root, tree), null, scan, vendored)
35
44
  } catch (e) {
36
- return new RepoView(walk(root), e instanceof Error ? e.message : String(e))
45
+ return new RepoView(walk(root), e instanceof Error ? e.message : String(e), null, vendored)
37
46
  }
38
47
  }
39
48
 
40
- /** the files matching `glob`, sorted */
49
+ /** the files matching `glob`, sorted, the vendored ones left out unless
50
+ * the glob names a path inside one */
41
51
  list(glob: string): string[] {
42
52
  const g = new Bun.Glob(glob)
43
- return this.files.filter((f) => g.match(f))
53
+ return listingWithoutVendored(this.files.filter((f) => g.match(f)), glob, this.vendored)
44
54
  }
45
55
  }
46
56
 
@@ -328,8 +328,10 @@ export function toolchainIdentity(repoRoot: string, cliEntry: string): Toolchain
328
328
  // stands.
329
329
  const lib = new LibSource(cliEntry)
330
330
  const r = createHash('sha256')
331
- // the runtime. Free, and a bun upgrade genuinely changes behaviour
332
- r.update(`bun=${Bun.version}\n`)
331
+ // the runtime. Free, and a bun upgrade genuinely changes behaviour. The
332
+ // revision too: every canary of one version reports the same version,
333
+ // and a canary upgrade can change what `bun lint` reports
334
+ r.update(`bun=${Bun.version}+${Bun.revision}\n`)
333
335
  for (const l of runnerLines(lib, cliEntry)) r.update(`${l}\n`)
334
336
  return { id: r.digest('hex').slice(0, 16), install: installOf(root, lib), lib }
335
337
  }
package/src/mv/mv-md.ts CHANGED
@@ -8,6 +8,8 @@
8
8
  import { execSync } from 'child_process'
9
9
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'fs'
10
10
  import { basename, dirname, join, relative } from 'path'
11
+ import { isVendored } from '../core/vendored.ts'
12
+ import { vendoredAround } from '../core/vendored-read.ts'
11
13
 
12
14
  export type MvIo = { print: (l: string) => void; printErr: (l: string) => void }
13
15
  export type MvOpts = { write?: boolean; commit?: boolean }
@@ -130,8 +132,11 @@ export function movePage(root: string, oldRel: string, newRel: string, io: MvIo,
130
132
  const bound = (t: string): RegExp =>
131
133
  new RegExp('(?<![\\w-])' + t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + (/[/.]$/.test(t) || t.endsWith('.ts') ? '' : '(?![\\w-])'), 'g')
132
134
 
135
+ // third party code keeps its own references, byte for byte
136
+ const repo = vendoredAround(root)
137
+ const vendored = (repo?.dirs ?? []).map((d) => posix(relative(root, join(repo!.root, d))))
133
138
  const tracked = sh('git ls-files', root).split('\n').filter(Boolean)
134
- .filter((f) => !/\.(png|jpe?g|gif|webp|ico|woff2?|ttf|zip|gz|lockb|lock)$/i.test(f))
139
+ .filter((f) => !/\.(png|jpe?g|gif|webp|ico|woff2?|ttf|zip|gz|lockb|lock)$/i.test(f) && !isVendored(f, vendored))
135
140
 
136
141
  // every path this move relocates, old → new. The sibling dir carries its
137
142
  // whole subtree, and the probe is renamed after the page, so its own entry
@@ -11,7 +11,8 @@
11
11
  //
12
12
  // what keeps it off the cpu: a save burst is asked about once the repo is
13
13
  // quiet (QUIET_MS, or MAX_WAIT_MS into a burst that never stops), a check a
14
- // save moved waits COST_FACTOR times its last measured run, and nothing it
14
+ // save moved waits COST_FACTOR times its last measured run and GATED_HOLD_MS
15
+ // after the latest gate someone else started in its repo, and nothing it
15
16
  // starts on its own starts while the load per core is above LOAD_MAX.
16
17
  import { availableParallelism, loadavg } from 'os'
17
18
  import { CATCH_UP_BEAT_MS, resumeCatchUp, type CatchUpStore } from './catch-up.ts'
@@ -39,6 +40,9 @@ export type PlanSource = {
39
40
  checks: () => Promise<PlanCheck[]>
40
41
  /** every check's last result on record */
41
42
  last: () => Record<string, LastResult>
43
+ /** when the latest run someone asked for started here (a terminal, an
44
+ * agent, a click), never one the plan started; null when none did */
45
+ gatedAt?: () => number | null
42
46
  /** the checks a save moved: their answer would not replay */
43
47
  changed: () => Promise<string[]>
44
48
  /** run these checks now, in the background; resolves to the exit code */
@@ -88,6 +92,11 @@ export const MAX_WAIT_MS = 10 * 60_000
88
92
  * measured run: a 30s suite at most every 10 minutes, a 200ms lint at once */
89
93
  export const COST_FACTOR = 20
90
94
 
95
+ /** a save moved check waits this long after the latest run someone else
96
+ * started in its repo (a terminal, an agent, a click): whoever gates that
97
+ * tree is still at work, and its own gate measures what it changed */
98
+ export const GATED_HOLD_MS = 10 * 60_000
99
+
91
100
  /** above this 1 minute load average per core, the plan starts nothing on its
92
101
  * own: every core already has a process waiting for it */
93
102
  export const LOAD_MAX = 1
@@ -112,6 +121,7 @@ type Repo = {
112
121
  src: PlanSource
113
122
  checks: PlanCheck[]
114
123
  last: Record<string, LastResult>
124
+ gatedAt: number | null
115
125
  lastRead: number
116
126
  marks: Map<string, Mark>
117
127
  timer: unknown
@@ -169,7 +179,7 @@ export class Planner {
169
179
  async add(src: PlanSource): Promise<void> {
170
180
  const old = this.repos.get(src.repo)
171
181
  if (old?.timer != null) this.timers.clear(old.timer)
172
- const r: Repo = { src, checks: [], last: {}, lastRead: -Infinity, marks: old?.marks ?? new Map(), timer: null, burstAt: null, settleAt: null, detecting: null, again: false, error: null }
182
+ const r: Repo = { src, checks: [], last: {}, gatedAt: null, lastRead: -Infinity, marks: old?.marks ?? new Map(), timer: null, burstAt: null, settleAt: null, detecting: null, again: false, error: null }
173
183
  this.repos.set(src.repo, r)
174
184
  await this.reload(src.repo)
175
185
  try {
@@ -223,6 +233,7 @@ export class Planner {
223
233
  r.lastRead = now
224
234
  try {
225
235
  r.last = r.src.last()
236
+ r.gatedAt = r.src.gatedAt?.() ?? null
226
237
  } catch (e) {
227
238
  this.fail(r.src.repo, e)
228
239
  }
@@ -315,7 +326,9 @@ export class Planner {
315
326
  // a check pays for its own runs: one save cannot run it again
316
327
  // before COST_FACTOR times its last measured run has passed
317
328
  const spaced = last?.cost != null ? last.cost.at + COST_FACTOR * last.cost.ms : -Infinity
318
- const at = Math.max(m.changedAt, spaced)
329
+ // and it waits for whoever gates this tree by hand to stop
330
+ const gated = r.gatedAt != null ? r.gatedAt + GATED_HOLD_MS : -Infinity
331
+ const at = Math.max(m.changedAt, spaced, gated)
319
332
  return { ...base, state: at > now ? 'next' : 'due', reason: 'changed', dueAt: at }
320
333
  }
321
334
  const lastAt = Math.max(last?.at ?? -Infinity, m.attemptAt ?? -Infinity)
@@ -354,8 +367,12 @@ export class Planner {
354
367
  held(): string[] {
355
368
  const now = this.now()
356
369
  const out: string[] = []
357
- for (const r of this.repos.values())
370
+ for (const r of this.repos.values()) {
358
371
  if (r.settleAt != null) out.push(`settling: ${r.src.repo}, ${Math.max(0, Math.ceil((r.settleAt - now) / 1_000))}s`)
372
+ const until = r.gatedAt == null ? null : r.gatedAt + GATED_HOLD_MS
373
+ if (until != null && until > now && [...r.marks.values()].some((m) => m.changedAt != null))
374
+ out.push(`deferred: ${r.src.repo}, a gate someone started there ${Math.floor((now - r.gatedAt!) / 60_000)}m ago, so what a save moved waits ${Math.ceil((until - now) / 60_000)}m`)
375
+ }
359
376
  if (this.holdReason != null) out.push(this.holdReason)
360
377
  return out
361
378
  }
@@ -82,12 +82,15 @@ export function readLastCosts(path: string): Record<string, { at: number; ms: nu
82
82
  return out
83
83
  }
84
84
 
85
- /** when the latest run on record started, whoever ran it, or null */
86
- export function lastStarted(path: string): number | null {
85
+ /** when the latest run on record started, or null. `asked`: only a run
86
+ * someone asked for (a terminal, an agent, a click), never one the plan
87
+ * started on its own */
88
+ export function lastStarted(path: string, o: { asked?: boolean } = {}): number | null {
87
89
  const opened = openDb(path, { create: false })
88
90
  if (opened == null) return null
89
91
  try {
90
- const r = opened.db.query('select max(started) as at from invocations').get() as { at: number | null } | null
92
+ const sql = o.asked === true ? "select max(started) as at from invocations where by != 'schedule'" : 'select max(started) as at from invocations'
93
+ const r = opened.db.query(sql).get() as { at: number | null } | null
91
94
  return r?.at == null ? null : Number(r.at)
92
95
  } catch {
93
96
  return null
@@ -144,6 +147,7 @@ export function repoSource(d: RepoSourceDeps): PlanSource {
144
147
  for (const [id, cost] of Object.entries(costs)) if (all[id] != null) all[id] = { ...all[id]!, cost }
145
148
  return all
146
149
  },
150
+ gatedAt: () => (d.record === '' ? null : lastStarted(d.record, { asked: true })),
147
151
  changed: async () => {
148
152
  const decls = rows.map((r) => r.decl)
149
153
  if (decls.length === 0) return []
@@ -1,6 +1,8 @@
1
1
  // file:line findings out of what a check printed, one function per format the
2
- // floor meets: tsc, oxlint (both its piped and its graphical output), eslint's
3
- // stylish output, a failing bun test, and a shipkit wire line naming a file.
2
+ // floor meets: tsc, bun lint (the unix format the built-in lint slot asks
3
+ // for), oxlint (both its piped and its graphical output, kept while a repo
4
+ // still lints with it: SK001 warns on one, and it dies with the last), eslint's stylish
5
+ // output, a failing bun test, and a shipkit wire line naming a file.
4
6
  // Every one feeds the one Problem shape (problem-line.ts).
5
7
  //
6
8
  // a finding the source cannot anchor in a file is dropped rather than pinned
@@ -62,6 +64,20 @@ export function fromTsc(text: string, ctx: ExtractCtx): Problem[] {
62
64
  return out
63
65
  }
64
66
 
67
+ const BUN_LINT_UNIX = /^(.+?):(\d+):(\d+): (.*) \[(Error|Warning)\/([^\]]+)\]$/
68
+
69
+ /** bun lint `--format unix`: `src/a.ts:1:7: msg [Warning/no-unused-vars]` */
70
+ export function fromBunLint(text: string, ctx: ExtractCtx): Problem[] {
71
+ const out: Problem[] = []
72
+ for (const raw of stripAnsi(text).split('\n')) {
73
+ const m = BUN_LINT_UNIX.exec(raw)
74
+ if (m == null) continue
75
+ const file = repoFile(ctx, m[1]!, false)
76
+ if (file != null) out.push({ file, line: Number(m[2]), col: Number(m[3]), severity: m[5] === 'Error' ? 'error' : 'warning', check: ctx.check, message: `${m[6]} ${m[4]!.trim()}` })
77
+ }
78
+ return out
79
+ }
80
+
65
81
  const OXLINT_PIPED = /^(.+?):(\d+):(\d+): (error|warning) (\S+?): (.*)$/
66
82
  const OXLINT_HEAD = /^\s*(x|!|×|⚠)\s+(.+)$/
67
83
  const OXLINT_AT = /^\s*(?:,-|╭─)\[(.+):(\d+):(\d+)\]\s*$/
@@ -148,14 +164,20 @@ export function fromBunTest(text: string, ctx: ExtractCtx): Problem[] {
148
164
 
149
165
  const WIRE_REF = /((?:[\w@.~-]+\/)*[\w@.~-]+\.[A-Za-z0-9]+):(\d+)(?::(\d+))?/g
150
166
 
151
- /** a FAIL or WARN wire line naming `path:line` or `path:line:col`. The path
152
- * must be a file this repo has: a url and a `host:port` look the same */
167
+ /** a FAIL or WARN wire line: at the site the check reported when it gave one,
168
+ * else at every `path:line` or `path:line:col` the text names. The path must
169
+ * be a file this repo has: a url and a `host:port` look the same */
153
170
  export function fromWire(lines: Line[], ctx: ExtractCtx): Problem[] {
154
171
  const out: Problem[] = []
155
172
  for (const l of lines) {
156
173
  if (l.kind !== 'fail' && l.kind !== 'warn') continue
157
174
  const severity: Severity = l.kind === 'fail' ? 'error' : 'warning'
158
175
  const message = l.text.replace(/^\[[^\]]+\]\s*/, '').trim()
176
+ if (l.at != null) {
177
+ const file = repoFile(ctx, l.at.file, true)
178
+ if (file != null) out.push({ file, line: l.at.line ?? 1, col: l.at.col ?? 1, severity, check: ctx.check, message })
179
+ continue
180
+ }
159
181
  for (const m of l.text.matchAll(WIRE_REF)) {
160
182
  const before = l.text.slice(0, m.index)
161
183
  if (/[a-z]+:\/\/\S*$/i.test(before)) continue
@@ -173,7 +195,7 @@ export function extractProblems(src: { lines: Line[]; output?: string }, ctx: Ex
173
195
  const text = src.output ?? ''
174
196
  const all = [
175
197
  ...fromWire(src.lines, ctx),
176
- ...(text === '' ? [] : [...fromTsc(text, ctx), ...fromOxlint(text, ctx), ...fromEslint(text, ctx), ...fromBunTest(text, ctx)]),
198
+ ...(text === '' ? [] : [...fromTsc(text, ctx), ...fromBunLint(text, ctx), ...fromOxlint(text, ctx), ...fromEslint(text, ctx), ...fromBunTest(text, ctx)]),
177
199
  ]
178
200
  const seen = new Set<string>()
179
201
  return all.filter((p) => {
@@ -1,7 +1,6 @@
1
1
  // the ONE shape a problem takes on the hub terminal, and the vscode problem
2
- // matcher that reads it back. The printer and every tasks.json writer import
3
- // this file, so the line the hub prints and the regex the editor matches can
4
- // never drift apart.
2
+ // matcher that reads it back, in one file so the two can never drift apart.
3
+ // No house task carries the matcher any more (SK017 removes hub tasks).
5
4
  //
6
5
  // a run prints one FRAME: the begin line, one problem line per finding, the
7
6
  // end line. The matcher is a background one, so vscode clears the pane at the