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
@@ -1,26 +1,24 @@
1
- // SK017 vscode hub task (build/vscode-tasks.md): the folder-open hub task
2
- // carries the problem matcher the hub prints for, it opens with the folder
3
- // itself when nothing else does, and no `👁 TSC` watcher runs beside a gate
4
- // that already runs `typecheck`.
1
+ // SK017 vscode hub task (build/vscode-tasks.md): no folder-open task starts
2
+ // the shipkit hub (it runs in the home, and VS Code needs no task for it), and
3
+ // no `👁 TSC` watcher runs beside a gate that already runs `typecheck`.
5
4
  //
6
- // the file is hand-written jsonc, so the fixer edits spans: it removes the TSC
7
- // task, a wrapper that only starts the hub, and their dependsOn entries, and
8
- // writes the hub's matcher and folder-open run option. Every other byte stays
9
- // as its author left it.
5
+ // the file is hand-written jsonc, so the fixer edits spans: it removes the hub
6
+ // task, the TSC task, a wrapper left starting nothing, and their dependsOn
7
+ // entries, and folds a wrapper left starting ONE task into it. Every other
8
+ // byte stays, except a dependsOn array it edits, which is rewritten inline.
9
+ // A file left holding nothing but an empty task list is deleted.
10
10
  import { basename } from 'path'
11
11
  import { check } from '../check.ts'
12
12
  import type { CheckCtx } from '../core/check-ctx.ts'
13
13
  import { checkFloorReader, resolveFloor } from '../core/floor-resolve.ts'
14
14
  import { rootPublishesNpm } from '../core/repo-facts.ts'
15
15
  import { repoOff } from '../core/when.ts'
16
- import { hubProblemMatcher } from '../problems/problem-line.ts'
17
- import { indentAt, parseSpans, plain, removalSpan, type Node } from '../scaffold/jsonc-spans.ts'
16
+ import { indentAt, parseSpans, plain, removalSpans, type Node } from '../scaffold/jsonc-spans.ts'
18
17
 
19
18
  export const TSC_LABEL = '👁 TSC'
20
19
  const TASKS = '.vscode/tasks.json'
21
20
 
22
21
  type Obj = Extract<Node, { kind: 'object' }>
23
- type Arr = Extract<Node, { kind: 'array' }>
24
22
 
25
23
  const prop = (o: Obj, key: string): Node | undefined => o.props.find((p) => p.key === key)?.value
26
24
 
@@ -35,12 +33,17 @@ function isTscWatcher(t: Record<string, unknown>): boolean {
35
33
  return t['label'] === TSC_LABEL && TSC_BINS.has(basename(cmd)) && args.includes('--watch')
36
34
  }
37
35
 
38
- /** the hub task: `bun run hub`, or the house label */
36
+ /** the hub task: `bun run hub` or `sk serve` (args or one shell string), or the house label */
39
37
  function isHub(t: Record<string, unknown>): boolean {
40
- const args = Array.isArray(t['args']) ? t['args'] : []
41
- return (t['command'] === 'bun' && args[0] === 'run' && args[1] === 'hub') || (typeof t['label'] === 'string' && /^🚀\s+shipkit\b/.test(t['label']))
38
+ const args = Array.isArray(t['args']) ? t['args'].map(String) : []
39
+ const line = [typeof t['command'] === 'string' ? t['command'] : '', ...args].join(' ')
40
+ return /(?:^|\s)(?:bun run hub|(?:sk|shipkit) serve)(?:\s|$)/.test(line) || (typeof t['label'] === 'string' && /^🚀\s+shipkit\b/.test(t['label']))
42
41
  }
43
42
 
43
+ /** the comment the old scaffold wrote above its hub task, byte for byte, any
44
+ * indent: it describes the hub task, so it goes with it */
45
+ const SCAFFOLD_HEADER = /^[ \t]*\/\/ the hub is the one process every wired repo starts, so it opens with the\n[ \t]*\/\/ folder itself\. It runs every check after each save and its problem\n[ \t]*\/\/ matcher fills the problems pane, so there is no type checker task\. A\n[ \t]*\/\/ stack that earns more long-running watchers \(dev server, css\) adds a\n[ \t]*\/\/ master task with runOn folderOpen whose dependsOn lists them all, the hub\n[ \t]*\/\/ included, and moves runOn off the hub\.\n/m
46
+
44
47
  /** a task that only starts other tasks: no command of its own */
45
48
  const isWrapper = (t: Record<string, unknown>): boolean => t['command'] == null && t['type'] == null && t['dependsOn'] != null
46
49
 
@@ -50,112 +53,111 @@ export type RepairOpts = {
50
53
  typecheckOn: boolean
51
54
  }
52
55
 
53
- /** what this file needs, applied: the text to write, one line per change, and
54
- * one line per thing it deliberately left. No change means the text comes
55
- * back byte for byte */
56
- export function repairTasks(text: string, o: RepairOpts): { text: string; changes: string[]; kept: string[] } {
56
+ /** what this file needs, applied: the text to write, one line per change,
57
+ * one line per thing it deliberately left, and whether any task is left. No
58
+ * change means the text comes back byte for byte */
59
+ export function repairTasks(text: string, o: RepairOpts): { text: string; changes: string[]; kept: string[]; empty: boolean } {
57
60
  const root = parseSpans(text)
58
61
  if (root.kind !== 'object') throw new Error('tasks.json is not an object')
59
62
  const tasks = prop(root, 'tasks')
60
- if (tasks == null || tasks.kind !== 'array') return { text, changes: [], kept: [] }
61
- const edits: { start: number; end: number; body: string }[] = []
63
+ if (tasks == null || tasks.kind !== 'array') return { text, changes: [], kept: [], empty: false }
62
64
  const changes: string[] = []
63
65
  const kept: string[] = []
64
66
  const record = (t: Node): Record<string, unknown> => plain(text, t) as Record<string, unknown>
65
67
  const labelOf = (t: Node): string => String(record(t)['label'] ?? '?')
66
- const items = tasks.items.filter((n): n is Obj => n.kind === 'object')
67
- const hubs = items.filter((t) => isHub(record(t)))
68
-
69
- const tscAt = tasks.items.flatMap((n, i) => (n.kind === 'object' && isTscWatcher(record(n)) ? [i] : []))
70
- const dropTsc = tscAt.length > 0 && o.typecheckOn
71
- if (tscAt.length > 0 && !o.typecheckOn) kept.push(`kept the '${TSC_LABEL}' task: this repo's gate has no enabled typecheck check, so it is the only type checker here`)
72
- if (dropTsc) for (const i of tscAt) {
73
- const [start, end] = removalSpan(text, tasks, i)
74
- edits.push({ start, end, body: '' })
75
- changes.push(`removed the '${TSC_LABEL}' task`)
68
+ const at = (pred: (t: Record<string, unknown>) => boolean): number[] => tasks.items.flatMap((n, i) => (n.kind === 'object' && pred(record(n)) ? [i] : []))
69
+
70
+ const remove = new Set<number>()
71
+ const gone = new Set<string>()
72
+ const drop = (i: number, why: string): void => {
73
+ remove.add(i)
74
+ gone.add(labelOf(tasks.items[i]!))
75
+ changes.push(why)
76
76
  }
77
+ for (const i of at(isHub)) drop(i, `removed the '${labelOf(tasks.items[i]!)}' hub task: the hub runs in the home, not in VS Code`)
78
+ const tscAt = at(isTscWatcher)
79
+ if (tscAt.length > 0 && !o.typecheckOn) kept.push(`kept the '${TSC_LABEL}' task: this repo's gate has no enabled typecheck check, so it is the only type checker here`)
80
+ if (o.typecheckOn) for (const i of tscAt) drop(i, `removed the '${TSC_LABEL}' task`)
77
81
 
78
- // a folder-open wrapper whose only job is starting the hub is a second
79
- // task on screen for nothing: the hub opens with the folder itself
80
- const hub = hubs.length === 1 ? hubs[0]! : null
81
- const hubLabel = hub == null ? null : labelOf(hub)
82
82
  const depsOf = (t: Node): string[] => {
83
83
  const d = record(t)['dependsOn']
84
- const list = Array.isArray(d) ? d.map(String) : [String(d)]
85
- return dropTsc ? list.filter((x) => x !== TSC_LABEL) : list
84
+ return d == null ? [] : Array.isArray(d) ? d.map(String) : [String(d)]
86
85
  }
87
- const wrapperAt = hub == null ? -1 : tasks.items.findIndex((n) => n.kind === 'object' && isWrapper(record(n)) && depsOf(n).length === 1 && depsOf(n)[0] === hubLabel)
88
- const wrapper = wrapperAt < 0 ? null : (tasks.items[wrapperAt] as Obj)
89
- if (wrapper != null) {
90
- const [start, end] = removalSpan(text, tasks, wrapperAt)
91
- edits.push({ start, end, body: '' })
92
- changes.push(`folded the '${labelOf(wrapper)}' wrapper into '${hubLabel}', which now opens with the folder`)
93
- if (/\/\/.*(master task|subtask)/i.test(text)) changes.push('a comment still describes a master task with subtasks: rewrite it by hand')
94
- }
95
-
96
- // a wrapper that started only the TSC task starts nothing once it goes
97
- const emptied = !dropTsc ? [] : tasks.items.flatMap((n, i) => (n !== wrapper && n.kind === 'object' && isWrapper(record(n)) && depsOf(n).length === 0 ? [i] : []))
98
- for (const i of emptied) {
99
- const [start, end] = removalSpan(text, tasks, i)
100
- edits.push({ start, end, body: '' })
101
- changes.push(`removed the '${labelOf(tasks.items[i]!)}' wrapper, which started nothing else`)
86
+ // a wrapper that started only what went starts nothing, and so on up
87
+ for (let moved = true; moved;) {
88
+ moved = false
89
+ tasks.items.forEach((n, i) => {
90
+ if (remove.has(i) || n.kind !== 'object' || !isWrapper(record(n))) return
91
+ const deps = depsOf(n)
92
+ if (deps.length > 0 && deps.every((x) => gone.has(x))) { drop(i, `removed the '${labelOf(n)}' wrapper, which started nothing else`); moved = true }
93
+ })
102
94
  }
103
95
 
104
- if (dropTsc) {
105
- for (const t of items) {
106
- if (t === wrapper || emptied.some((i) => tasks.items[i] === t)) continue
107
- const d = prop(t, 'dependsOn')
108
- if (d?.kind === 'array') {
109
- const idx = d.items.findIndex((x) => x.kind === 'scalar' && x.value === TSC_LABEL)
110
- if (idx < 0) continue
111
- const [start, end] = removalSpan(text, d as Arr, idx)
112
- edits.push({ start, end, body: '' })
113
- changes.push(`dropped '${TSC_LABEL}' from the dependsOn of '${labelOf(t)}'`)
114
- } else if (d?.kind === 'scalar' && d.value === TSC_LABEL) {
115
- edits.push({ start: d.start, end: d.end, body: '[]' })
116
- changes.push(`dropped '${TSC_LABEL}' from a dependsOn`)
117
- }
118
- }
119
- }
120
-
121
- const want = hubProblemMatcher()
122
- for (const h of hubs) {
123
- const first = h.props[0]
124
- const hubIndent = indentAt(text, h.start)
125
- const propIndent = first != null ? indentAt(text, first.keyStart) : `${hubIndent} `
126
- const unit = propIndent.length > hubIndent.length ? propIndent.slice(hubIndent.length) : ' '
127
- const render = (v: unknown): string => JSON.stringify(v, null, unit).split('\n').join(`\n${propIndent}`)
96
+ const edits: { start: number; end: number; body: string }[] = []
97
+ // a wrapper this pass cut down to ONE task is two tasks on screen for one
98
+ // process: it folds into that task, which takes the wrapper's own runOn
99
+ // and group, and every dependsOn naming the wrapper names the task instead
100
+ const alias = new Map<string, string>()
101
+ const folds = new Map<Obj, Obj[]>()
102
+ tasks.items.forEach((n, i) => {
103
+ if (remove.has(i) || n.kind !== 'object' || !isWrapper(record(n))) return
104
+ const deps = depsOf(n)
105
+ const left = deps.filter((x) => !gone.has(x))
106
+ if (left.length !== 1 || left.length === deps.length) return
107
+ const into = tasks.items.findIndex((t, j) => !remove.has(j) && t.kind === 'object' && labelOf(t) === left[0])
108
+ if (into < 0) return
109
+ remove.add(i)
110
+ alias.set(labelOf(n), left[0]!)
111
+ folds.set(tasks.items[into] as Obj, [...(folds.get(tasks.items[into] as Obj) ?? []), n])
112
+ const opens = (record(n)['runOptions'] as Record<string, unknown> | undefined)?.['runOn'] === 'folderOpen'
113
+ changes.push(`folded the '${labelOf(n)}' wrapper into '${left[0]}'${opens ? ', which now opens with the folder' : ''}`)
114
+ })
115
+ tasks.items.forEach((n, i) => {
116
+ if (remove.has(i) || n.kind !== 'object') return
117
+ const d = prop(n, 'dependsOn')
118
+ const deps = depsOf(n)
119
+ const next = [...new Set(deps.map((x) => alias.get(x) ?? x).filter((x) => !gone.has(x) && x !== labelOf(n)))]
120
+ if (d == null || JSON.stringify(next) === JSON.stringify(deps)) return
121
+ edits.push({ start: d.start, end: d.end, body: JSON.stringify(next).replace(/","/g, '", "') })
122
+ changes.push(`dropped ${deps.filter((x) => !next.includes(x)).map((x) => `'${x}'`).join(', ')} from the dependsOn of '${labelOf(n)}'`)
123
+ })
124
+ for (const [start, end] of removalSpans(text, tasks, remove)) edits.push({ start, end, body: '' })
125
+ // the default build group a removed hub held moves with the fold too
126
+ const hubGroup = at(isHub).map((i) => prop(tasks.items[i] as Obj, 'group')).find((g) => g != null && /"isDefault"\s*:\s*true/.test(text.slice(g.start, g.end)))
127
+ for (const [task, wrappers] of folds) {
128
+ const first = task.props[0]
129
+ const taskIndent = indentAt(text, task.start)
130
+ const propIndent = first != null ? indentAt(text, first.keyStart) : `${taskIndent} `
128
131
  const added: string[] = []
129
- const current = prop(h, 'problemMatcher')
130
- if (current == null || JSON.stringify(plain(text, current)) !== JSON.stringify(want)) {
131
- if (current != null) edits.push({ start: current.start, end: current.end, body: render(want) })
132
- else added.push(`"problemMatcher": ${render(want)}`)
133
- changes.push(`wrote the shipkit problem matcher on '${labelOf(h)}'`)
134
- }
135
- if (h === hub && wrapper != null) {
136
- const ro = prop(h, 'runOptions')
132
+ const runOn = wrappers.map((w) => record(w)['runOptions'] as Record<string, unknown> | undefined).find((r) => r?.['runOn'] != null)?.['runOn']
133
+ if (runOn != null) {
134
+ const ro = prop(task, 'runOptions')
137
135
  const was = ro != null && ro.kind === 'object' ? record(ro) : {}
138
- const inline = `{ ${Object.entries(Object.assign({ runOn: 'folderOpen', instanceLimit: 1 }, was, { runOn: 'folderOpen' })).map(([k, v]) => `${JSON.stringify(k)}: ${JSON.stringify(v)}`).join(', ')} }`
136
+ const inline = `{ ${Object.entries({ ...was, runOn, instanceLimit: was['instanceLimit'] ?? 1 }).map(([k, v]) => `${JSON.stringify(k)}: ${JSON.stringify(v)}`).join(', ')} }`
139
137
  if (ro != null) edits.push({ start: ro.start, end: ro.end, body: inline })
140
138
  else added.push(`"runOptions": ${inline}`)
141
- const wg = prop(wrapper, 'group')
142
- if (wg != null) {
143
- const body = text.slice(wg.start, wg.end)
144
- const g = prop(h, 'group')
145
- if (g != null) edits.push({ start: g.start, end: g.end, body })
146
- else added.push(`"group": ${body}`)
147
- }
148
139
  }
149
- if (added.length === 0) continue
150
- const last = h.props.at(-1)
151
- const joined = added.join(`,\n${propIndent}`)
152
- if (last == null) edits.push({ start: h.start + 1, end: h.start + 1, body: `\n${propIndent}${joined}\n${hubIndent}` })
153
- else edits.push({ start: last.value.end, end: last.value.end, body: `,\n${propIndent}${joined}` })
140
+ const wg = wrappers.map((w) => prop(w, 'group')).find((g) => g != null) ?? hubGroup
141
+ if (wg != null) {
142
+ const body = text.slice(wg.start, wg.end)
143
+ const g = prop(task, 'group')
144
+ if (g != null) edits.push({ start: g.start, end: g.end, body })
145
+ else added.push(`"group": ${body}`)
146
+ }
147
+ const last = task.props.at(-1)
148
+ if (added.length > 0 && last != null) edits.push({ start: last.value.end, end: last.value.end, body: `,\n${propIndent}${added.join(`,\n${propIndent}`)}` })
154
149
  }
155
150
 
151
+ const header = gone.size > 0 ? SCAFFOLD_HEADER.exec(text) : null
152
+ if (header != null) {
153
+ edits.push({ start: header.index, end: header.index + header[0].length, body: '' })
154
+ changes.push('removed the old scaffold comment that described the hub task')
155
+ }
156
156
  let out = text
157
157
  for (const e of edits.sort((a, b) => b.start - a.start)) out = out.slice(0, e.start) + e.body + out.slice(e.end)
158
- return { text: out, changes, kept }
158
+ // empty only when nothing else lives in the file either (`inputs`, a key of its own)
159
+ const onlyTasks = root.props.every((x) => x.key === 'version' || x.key === 'tasks')
160
+ return { text: out, changes, kept, empty: onlyTasks && tasks.items.length > 0 && remove.size === tasks.items.length }
159
161
  }
160
162
 
161
163
  /** whether this repo's gate runs a typecheck: a floor slot or a task that
@@ -184,26 +186,26 @@ const PUBLIC_SKIP = `the root unit publishes to npm: ${TASKS} belongs to every c
184
186
  export const vscodeHubTask = check({
185
187
  id: 'SK017',
186
188
  // a stale editor file is behind, not broken: it warns, and the fix pass
187
- // repairs it
189
+ // of every gate repairs it
188
190
  blocking: false,
189
191
  title: 'vscode hub task',
190
- description: 'the vscode hub task carries the problem matcher and no extra tsc watcher runs',
192
+ description: 'no vscode task starts the shipkit hub, and no extra tsc watcher runs',
191
193
  owner: 'build/vscode-tasks.md',
192
- runs: { onPreCommit: false, onCi: false, onFull: false, onRelease: false, onHealth: true, onSave: 'moved', onClock: false },
194
+ runs: { onPreCommit: false, onCi: true, onFull: true, onRelease: false, onHealth: true, onSave: 'moved', onClock: false },
193
195
  cache: { by: 'reads' },
194
- fix: 'manual',
196
+ fix: 'ci',
195
197
  applies: 'always',
196
198
  run: (c) => {
197
199
  if (rootPublishesNpm(c.config)) return c.ok(PUBLIC_SKIP)
198
200
  const own = ownTasks(c)
199
- if (own === 'none') return c.ok(`no ${TASKS}, so there is no hub task to wire`)
201
+ if (own === 'none') return c.ok(`no ${TASKS}, so no task starts the hub`)
200
202
  if (own === 'linked') return c.ok(`.vscode/ is a link to a shared dir, left alone`)
201
203
  let r: { changes: string[]; kept: string[] }
202
204
  try { r = repairTasks(own.text, { typecheckOn: typecheckOn(c) }) }
203
205
  catch (e) { return c.warn(`${TASKS} does not parse (${(e as Error).message}), so the hub task was not checked`) }
204
206
  for (const k of r.kept) c.note(`${TASKS}: ${k}`)
205
- if (r.changes.length === 0) return c.ok(`${TASKS}: the hub task feeds the problems pane`)
206
- for (const ch of r.changes) c.fail(`${TASKS}: ${ch.replace(/^removed/, 'should remove').replace(/^dropped/, 'should drop').replace(/^wrote/, 'should write').replace(/^folded/, 'should fold')}`)
207
+ if (r.changes.length === 0) return c.ok(`${TASKS}: no task starts the hub`)
208
+ for (const ch of r.changes) c.fail(`${TASKS}: ${ch.replace(/^removed/, 'should remove').replace(/^dropped/, 'should drop')}`)
207
209
  },
208
210
  repair: (c) => {
209
211
  if (rootPublishesNpm(c.config)) return { changed: false, summary: PUBLIC_SKIP }
@@ -211,8 +213,9 @@ export const vscodeHubTask = check({
211
213
  if (typeof own === 'string') return { changed: false, summary: `no ${TASKS} to repair` }
212
214
  const r = repairTasks(own.text, { typecheckOn: typecheckOn(c) })
213
215
  if (r.changes.length === 0) return { changed: false, summary: `${TASKS} already current` }
214
- c.write(TASKS, r.text)
215
- return { changed: true, summary: `${TASKS}: ${r.changes.join(', ')}` }
216
+ if (r.empty) c.remove(TASKS)
217
+ else c.write(TASKS, r.text)
218
+ return { changed: true, summary: `${TASKS}: ${r.changes.join(', ')}${r.empty ? ', and deleted the file, which held no other task' : ''}` }
216
219
  },
217
220
  })
218
221
 
@@ -15,6 +15,7 @@ import { check } from '../check.ts'
15
15
  import { repoCheckSources } from '../core/legacy-shapes.ts'
16
16
  import { argvRunsTests, commandRunsTests, sourceRunsTests, type ScriptReader } from '../suite/runner-shapes.ts'
17
17
  import type { CheckCtx } from '../core/check-ctx.ts'
18
+ import { repoOff } from '../core/when.ts'
18
19
 
19
20
  /** the scripts of the package.json in `dir` (repo relative), then the root's */
20
21
  const scriptsIn = (c: CheckCtx, dir: string): ScriptReader => {
@@ -67,7 +68,7 @@ export const testsRunInUnits = check({
67
68
  }
68
69
  // a slot the package.json fills: its raw body, else its own script
69
70
  for (const id of OTHER_SLOTS) {
70
- if (defaults[id] === false || (defaults[id] as { cmd?: unknown } | undefined)?.cmd != null) continue
71
+ if (repoOff(c.config, id) != null || (defaults[id] as { cmd?: unknown } | undefined)?.cmd != null) continue
71
72
  const name = root(`${id}:raw`) != null ? `${id}:raw` : id
72
73
  const body = root(name)
73
74
  const how = body == null ? null : commandRunsTests(body, (n) => (n === name ? null : root(n)))
@@ -30,9 +30,12 @@ export const agentWikiLinks = check({
30
30
  for (const f of orphans) {
31
31
  const by = namingPage(c.root, f, linked)
32
32
  const where = by != null ? `${by}, which already names it (turn that mention into the link)` : 'agent/index.md or the page of its topic'
33
- c.fail(`agent/${f}: orphan, unreachable from agent/index.md: add a markdown link to it from ${where}; delete it only if the page is dead`)
33
+ c.fail(`agent/${f}: orphan, unreachable from agent/index.md: add a markdown link to it from ${where}; delete it only if the page is dead`, { at: { file: `agent/${f}` } })
34
+ }
35
+ for (const d of lint.dangling) {
36
+ const file = `agent/${d.from}`
37
+ c.fail(`${file}:${d.line}: dangling ref → ${d.token}, target not found on disk: fix the path or delete the pointer (a pointer out of this repo, into a house wiki or another repo, goes in backticks)`, { at: { file, line: d.line } })
34
38
  }
35
- for (const d of lint.dangling) c.fail(`agent/${d.from}: dangling ref → ${d.token}, target not found on disk: fix the path or delete the pointer (a pointer out of this repo, into a house wiki or another repo, goes in backticks)`)
36
39
  if (!c.failed) c.ok('agent/ has no orphans and no dangling refs')
37
40
  },
38
41
  })
@@ -5,28 +5,44 @@
5
5
  // keys resolved to (core/port-check-file.ts), so the check keeps running
6
6
  // where it ran, and the ci fix pass applies it before the gate reads the
7
7
  // files: a repo carried across by its first `shipkit ci`. The same holds for
8
- // the config: a retired `when` (only its `off:<why>` entries still load) and
9
- // `ruleExempt` become `off` entries in the managed block.
8
+ // the config: a retired `when` (only its `off:<why>` entries still load),
9
+ // `ruleExempt`, `hooks.disable` and a `defaults` slot set to false become
10
+ // `off` entries in the managed block.
10
11
  import { join } from 'path'
11
12
  import { check, type FixResult } from '../check.ts'
12
13
  import { unportedCheckFiles } from '../core/port-check-files.ts'
13
14
  import type { PortRun } from '../core/port-check-cli.ts'
14
15
  import type { ConfigPlan, ConfigRewrite } from '../core/config-rewrite.ts'
15
16
  import type { RepoConfig } from '../repo-config.ts'
17
+ import { legacyOffReason } from '../core/when.ts'
16
18
 
17
19
  /** the write half, run as its own process: it walks import closures on disk */
18
20
  const PORT_CLI = join(import.meta.dir, '../core/port-check-cli.ts')
19
21
  /** the config half, run as its own process: it loads repo.config.ts */
20
22
  const CONFIG_REWRITE = join(import.meta.dir, '../core/config-rewrite.ts')
21
23
 
22
- /** the retired config keys this repo still carries, and the `off` entries
23
- * they become. defineRepo already refused every `when` word but `off:<why>` */
24
- export function retiredOff(c: RepoConfig): { keys: string[]; off: Record<string, string> } {
24
+ /** the retired config spellings this repo still carries, and the `off`
25
+ * entries they become. defineRepo already refused every `when` word but
26
+ * `off:<why>`. `hooks.disable` and a `defaults` slot set to false never said
27
+ * why, so they are lifted with the comment above them, else with a reason
28
+ * that says none was recorded (core/when.ts legacyOffReason) */
29
+ export function retiredOff(c: RepoConfig): { keys: string[]; off: Record<string, string>; lift: NonNullable<ConfigPlan['lift']> } {
25
30
  const off: Record<string, string> = {}
26
31
  for (const [id, raw] of Object.entries(c.when ?? {})) off[id] = raw.trim().replace(/^off:?/, '').trim()
27
32
  for (const [id, why] of Object.entries(c.ruleExempt ?? {})) off[id] ??= why
28
- const keys = [...(c.when != null ? ['when'] : []), ...(c.ruleExempt != null ? ['ruleExempt'] : [])]
29
- return { keys, off }
33
+ const lift: NonNullable<ConfigPlan['lift']> = []
34
+ const disable = c.hooks?.disable ?? []
35
+ if (c.hooks?.disable != null)
36
+ lift.push({ parent: 'hooks', entry: 'disable', value: '\\[[^\\]\\[]*\\]', ids: disable, reason: Object.fromEntries(disable.map((id) => [id, legacyOffReason('hooks.disable', id)])) })
37
+ const slots = Object.entries(c.defaults ?? {}).filter(([, d]) => d === false).map(([id]) => id)
38
+ for (const id of slots) lift.push({ parent: 'defaults', entry: id, value: 'false', ids: [id], reason: { [id]: legacyOffReason('defaults', id) } })
39
+ const keys = [
40
+ ...(c.when != null ? ['when'] : []),
41
+ ...(c.ruleExempt != null ? ['ruleExempt'] : []),
42
+ ...(c.hooks?.disable != null ? ['hooks.disable'] : []),
43
+ ...slots.map((id) => `defaults.${id}: false`),
44
+ ]
45
+ return { keys, off, lift }
30
46
  }
31
47
 
32
48
  export const explicitCheckFiles = check({
@@ -76,6 +92,7 @@ export const explicitCheckFiles = check({
76
92
  ...(c.config.ruleExempt != null ? [{ key: 'ruleExempt', why: 'its exemptions are `off` now', phase: 'afterOff' as const }] : []),
77
93
  ],
78
94
  name: c.config.name,
95
+ lift: cfg.lift,
79
96
  }
80
97
  const r = await c.exec([process.execPath, CONFIG_REWRITE, c.root, JSON.stringify(plan)], { cwd: c.root, timeoutMs: 60_000 })
81
98
  try {
@@ -84,7 +101,7 @@ export const explicitCheckFiles = check({
84
101
  hand.push(...out.hand)
85
102
  if (out.restored != null) hand.push(`repo.config.ts was left as it was: ${out.restored}`)
86
103
  } catch {
87
- hand.push(`the repo.config.ts rewrite did not answer (exit ${r.code}): write off: ${JSON.stringify(cfg.off)} by hand and drop ${cfg.keys.join(', ')}`)
104
+ hand.push(`the repo.config.ts rewrite did not answer (exit ${r.code}): write off: ${JSON.stringify({ ...Object.assign({}, ...cfg.lift.map((l) => l.reason)), ...cfg.off })} by hand and drop ${cfg.keys.join(', ')}`)
88
105
  }
89
106
  }
90
107
  const summary = [did.length > 0 ? did.join('; ') : 'nothing ported', ...(hand.length > 0 ? [`${hand.length} by hand: ${hand.join(' | ')}`] : [])].join('. ')
@@ -0,0 +1,90 @@
1
+ // SK034 commit gates armed (build/shipkit/commit-gates.md): the leak guard and
2
+ // the message gate run only from git's hooks, so a repo whose hooksPath is
3
+ // unset, or points at a foreign dir that never calls shipkit, commits with
4
+ // both dark and nothing says so. `sk ci` arms an unset path; a foreign one is
5
+ // the repo's own, so the check names the line to chain into it.
6
+ import { readFileSync } from 'fs'
7
+ import { isAbsolute, join } from 'path'
8
+ import { nestedIn } from '../core/nesting.ts'
9
+ import { gitBin } from '../core/git-bin.ts'
10
+ import { fileStamp } from '../core/file-stamp.ts'
11
+ import { CONFIG_FILE } from '../core/check-pass.ts'
12
+ import { LAYOUT } from '../core/layout.ts'
13
+ import { check, type CheckEnv } from '../check.ts'
14
+ import { hookBinInvocation } from '../hooks/run-hooks.ts'
15
+
16
+ const HOOKS = [
17
+ { name: 'pre-commit', arg: '', guards: 'the leak guard' },
18
+ { name: 'commit-msg', arg: ' "$1"', guards: 'the message gate' },
19
+ ] as const
20
+
21
+ const readOrNull = (abs: string): string | null => {
22
+ try {
23
+ return readFileSync(abs, 'utf8')
24
+ } catch {
25
+ return null
26
+ }
27
+ }
28
+
29
+ /** a hook body that runs the shipkit gate: a live line (never a comment)
30
+ * whose failure is not swallowed by `|| true` */
31
+ export const chainsShipkit = (body: string, hook: string): boolean =>
32
+ body.split('\n').some((l) => !l.trim().startsWith('#') && new RegExp(`\\bhooks\\s+${hook}\\b`).test(l) && !/\|\|\s*(?:true|:)\b/.test(l))
33
+
34
+ /** the line to paste into a foreign hook: a clone with no install skips the
35
+ * gate with a note, rather than failing every commit */
36
+ export const guardedChain = (bin: string, hook: string, arg: string): string => {
37
+ const cli = bin.replace(/^bun\s+/, '')
38
+ return `if [ -f ${cli} ]; then ${bin} hooks ${hook}${arg} || exit 1; else echo "${hook}: rvlib-shipkit not installed (run bun install), shipkit gate skipped" >&2; fi`
39
+ }
40
+
41
+ const hooksDir = (root: string): string => {
42
+ const p = Bun.spawnSync([gitBin(), 'config', 'core.hooksPath'], { cwd: root, stdout: 'pipe', stderr: 'ignore' })
43
+ return p.exitCode === 0 ? p.stdout.toString().trim() : ''
44
+ }
45
+
46
+ /** the hooksPath and the two hook files it points at. A commit moves none */
47
+ export function gatesKey(env: CheckEnv): string {
48
+ const nested = nestedIn(env.root)
49
+ if (nested != null) return `nested in ${nested}`
50
+ const dir = hooksDir(env.root)
51
+ if (dir === '') return 'hooks='
52
+ const at = (h: string): string => fileStamp(isAbsolute(dir) ? join(dir, h) : join(env.root, dir, h))
53
+ return `hooks=${dir}|${HOOKS.map((h) => `${h.name}=${at(h.name)}`).join('|')}`
54
+ }
55
+
56
+ export const commitGatesArmed = check({
57
+ id: 'SK034',
58
+ blocking: true,
59
+ title: 'commit gates armed',
60
+ description: 'git runs the shipkit pre-commit and commit-msg gates on every commit',
61
+ owner: 'build/shipkit/commit-gates.md',
62
+ runs: { onPreCommit: false, onCi: true, onFull: true, onRelease: true, onHealth: true, onSave: 'moved', onClock: false },
63
+ // git config and hook files outside any tree id, so they are the key
64
+ cache: { by: 'files', inputs: [CONFIG_FILE], key: gatesKey },
65
+ fix: 'none',
66
+ applies: 'always',
67
+ run: async (c) => {
68
+ const nested = nestedIn(c.root)
69
+ if (nested != null) return void c.lines.push({ kind: 'skip', text: `commit gates not checked, nested in ${nested}: the parent repo owns them` })
70
+ const top = await c.exec(['git', 'rev-parse', '--show-toplevel'], { cwd: c.root })
71
+ if (top.code !== 0) return void c.lines.push({ kind: 'skip', text: 'not a git repo: no commit to guard yet' })
72
+ const hp = await c.exec(['git', 'config', 'core.hooksPath'], { cwd: c.root })
73
+ const dir = hp.code === 0 ? hp.out.trim() : ''
74
+ if (dir === '' && process.env['CI'] != null && process.env['CI'] !== '')
75
+ return void c.lines.push({ kind: 'skip', text: 'commit gates not armed in this CI checkout: a CI run makes no commit, and the fix pass arms hooks on a machine only' })
76
+ if (dir === '') return void c.fail('the commit gates are not armed, so a commit runs no leak guard and no message gate: run `shipkit hooks install` (`shipkit ci` arms them too)')
77
+ const own = dir === LAYOUT.hooks
78
+ const bin = hookBinInvocation(c.root)
79
+ for (const h of HOOKS) {
80
+ const body = isAbsolute(dir) ? readOrNull(join(dir, h.name)) : c.read(`${dir}/${h.name}`)
81
+ if (body == null)
82
+ c.fail(`core.hooksPath=${dir} has no ${h.name}, so ${h.guards} never runs: ${own ? 'run `shipkit hooks install`' : `add one that runs \`${guardedChain(bin, h.name, h.arg)}\``}`)
83
+ else if (!chainsShipkit(body, h.name))
84
+ c.fail(`core.hooksPath=${dir}: its ${h.name} does not run the shipkit gate, so ${h.guards} never runs: add \`${guardedChain(bin, h.name, h.arg)}\` above its last line`)
85
+ }
86
+ if (!c.failed) c.ok(`git runs the shipkit commit gates (${own ? dir : `${dir}, chained to shipkit`})`)
87
+ },
88
+ })
89
+
90
+ export default commitGatesArmed
package/src/cli/cmd_Ci.ts CHANGED
@@ -14,13 +14,14 @@ export class CiCommand extends Command {
14
14
  static override usage = Command.Usage({
15
15
  category: 'Gate',
16
16
  description: 'THE gate: repairs what a machine can, then runs every check. Run this, not the parts',
17
- details: 'THE command. Repairs what a machine can (formatter, imports, repo shape), then runs every check the gate asks for, through one DAG. A check that declares blocking:false warns instead of failing. Run this instead of anything else.',
17
+ details: 'THE command. Repairs what a machine can (imports, repo shape), then runs every check the gate asks for, through one DAG. A check that declares blocking:false warns instead of failing. Run this instead of anything else.',
18
18
  examples: [
19
19
  ['full gate, mechanical fixes applied', 'sk ci'],
20
20
  ['gate without the repair pass (checks can still write)', 'sk ci --no-fix'],
21
21
  ['leave repo config files alone', 'sk ci --no-fix-rules'],
22
22
  ['stop at the first failure', 'sk ci --bail'],
23
- ['one check + its dep closure', 'sk ci --only typecheck'],
23
+ ['one check + its dep closure, the tree left alone', 'sk ci --only typecheck'],
24
+ ['one check, its own repair first', 'sk ci --only SK004 --fix'],
24
25
  ['every check declaring one group', 'sk ci --only wiki'],
25
26
  ['run everything, take nothing from the cache', 'sk ci --fresh'],
26
27
  ['include an opt in check', 'sk ci --with up:itch'],
@@ -31,12 +32,12 @@ export class CiCommand extends Command {
31
32
 
32
33
  bail = Option.Boolean('--bail', false, { description: 'stop at the first failure' })
33
34
  json = Option.Boolean('--json', false, { description: 'machine-readable result instead of wire lines' })
34
- fix = Option.Boolean('--fix', true, { description: 'repair the mechanical slots before gating. Default on. --no-fix skips the repair pass, the checks can still write. Under CI the fixers still run but the gate FAILS on any file they rewrite' })
35
+ fix = Option.Boolean('--fix', { description: 'repair before gating. Default on for a whole gate and off for a --only run, which leaves the tree alone unless --fix is named, and then repairs only what the named checks own. Nothing ever formats. --no-fix skips the repair pass, the checks can still write. Under CI the fixers still run but the gate FAILS on any file they rewrite' })
35
36
  fixRules = Option.Boolean('--fix-rules', true, { description: 'apply the structural autofixes of the built-in checks too. Default on. --no-fix-rules leaves repo config alone' })
36
37
  frozen = Option.Boolean('--frozen', false, { description: 'report-only mode for self-maintaining checks: SK005 never touches package.json, and generated paths are never untracked. STATUS.md is still refreshed on disk, but it is gitignored, so a run leaves the tree clean' })
37
38
  skip = Option.Array('--skip', [], { description: 'skip a check group (repeatable)' })
38
39
  withIds = Option.Array('--with', [], { description: 'include a check the quick gate leaves out (repeatable)' })
39
- only = Option.Array('--only', [], { description: 'run only this check + its dep closure (repeatable). A group name runs every check declaring that group' })
40
+ only = Option.Array('--only', [], { description: 'run only this check + its dep closure (repeatable). A group name runs every check declaring that group. No repair pass unless --fix is named' })
40
41
  fresh = Option.Boolean('--fresh', false, { description: 'run every check: no answer comes from the cache, and what this run finds is stored fresh' })
41
42
  full = Option.Boolean('--full', false, { description: `run the FULL test tier: every suite the repo declares, the slow ones it skips while you work included. What the release path always runs. Same as SHIPKIT_TEST_FULL=1` })
42
43
 
@@ -68,7 +69,7 @@ export class CiCommand extends Command {
68
69
  return [
69
70
  ...(this.bail ? ['--bail'] : []),
70
71
  ...(this.json ? ['--json'] : []),
71
- ...(this.fix ? [] : ['--no-fix']),
72
+ ...(this.fix === true ? ['--fix'] : this.fix === false ? ['--no-fix'] : []),
72
73
  ...(this.fixRules ? [] : ['--no-fix-rules']),
73
74
  ...(this.frozen ? ['--frozen'] : []),
74
75
  ...this.skip.flatMap((v) => ['--skip', v]),
@@ -71,6 +71,20 @@ export function renderMarkdown(rows: CommandRow[]): string {
71
71
  return out.join('\n').trim()
72
72
  }
73
73
 
74
+ /** the eager form: one line per command, its positional arguments and its
75
+ * description. Flags and examples are `sk <command> --help`, so a page every
76
+ * session loads costs a line per command, not a screen */
77
+ export function renderCompact(rows: CommandRow[]): string {
78
+ const out: string[] = []
79
+ let cat = ''
80
+ for (const r of rows) {
81
+ if (r.category !== cat) { cat = r.category; out.push(`\n### ${cat}\n`) }
82
+ const sig = r.usage.replace(/^\$0/, 'sk').replace(/\s+\[(?:-|<\w+>)[^\]]*\]/g, '').trim() || `sk ${r.path}`
83
+ out.push(`- \`${sig}\` — ${r.description}`)
84
+ }
85
+ return out.join('\n').trim()
86
+ }
87
+
74
88
  export const BEGIN = '<!-- shipkit:commands -->'
75
89
  export const END = '<!-- /shipkit:commands -->'
76
90
 
@@ -89,14 +103,16 @@ export class CommandsCommand extends Command {
89
103
  examples: [
90
104
  ['markdown index', 'sk commands'],
91
105
  ['machine-readable', 'sk commands --json'],
92
- ['write into a docs page between the markers', 'sk commands --out wiki/build/shipkit-cli.md'],
93
- ['fail if that page is stale (lint mode)', 'sk commands --out wiki/build/shipkit-cli.md --check'],
106
+ ['write into a docs page between the markers', 'sk commands --out docs/commands.md'],
107
+ ['one line per command, for a page every session loads', 'sk commands --compact --out wiki/build/shipkit-cli.md'],
108
+ ['fail if that page is stale (lint mode)', 'sk commands --compact --out wiki/build/shipkit-cli.md --check'],
94
109
  ],
95
110
  })
96
111
 
97
112
  json = Option.Boolean('--json', false, { description: 'machine-readable [{path, category, description}]' })
98
113
  out = Option.String('--out', { description: 'host file carrying the shipkit:commands markers. The block between them is rewritten' })
99
114
  check = Option.Boolean('--check', false, { description: 'with --out: exit 1 if the block is stale, write nothing' })
115
+ compact = Option.Boolean('--compact', false, { description: 'one line per command: no flags, no examples (they are `sk <command> --help`)' })
100
116
 
101
117
  async execute(): Promise<number> {
102
118
  const rows = commandRows(
@@ -104,18 +120,18 @@ export class CommandsCommand extends Command {
104
120
  (this.cli.definitions as (o?: { detailed?: boolean }) => Definition[])({ detailed: true }),
105
121
  )
106
122
  if (this.out == null) {
107
- this.context.stdout.write(this.json ? `${JSON.stringify(rows)}\n` : `${renderMarkdown(rows)}\n`)
123
+ this.context.stdout.write(this.json ? `${JSON.stringify(rows)}\n` : `${(this.compact ? renderCompact : renderMarkdown)(rows)}\n`)
108
124
  return 0
109
125
  }
110
126
  const { existsSync, readFileSync, writeFileSync } = await import('fs')
111
127
  if (!existsSync(this.out)) { this.context.stdout.write(`FAIL: ${this.out} does not exist\n`); return 1 }
112
128
  const host = readFileSync(this.out, 'utf8')
113
- const next = spliceBlock(host, renderMarkdown(rows))
129
+ const next = spliceBlock(host, (this.compact ? renderCompact : renderMarkdown)(rows))
114
130
  if (next == null) { this.context.stdout.write(`FAIL: ${this.out} has no ${BEGIN} / ${END} markers\n`); return 1 }
115
131
  if (next === host) { this.context.stdout.write(`ok: ${this.out} command index current (${rows.length} commands)\n`); return 0 }
116
132
  if (this.check) {
117
133
  this.context.stdout.write(`FAIL: ${this.out} command index is STALE — the binary registers commands the page does not list\n`)
118
- this.context.stdout.write(` fix: shipkit commands --out ${this.out}\n`)
134
+ this.context.stdout.write(` fix: shipkit commands${this.compact ? ' --compact' : ''} --out ${this.out}\n`)
119
135
  return 1
120
136
  }
121
137
  writeFileSync(this.out, next)