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.
- package/CHANGELOG.md +66 -0
- package/package.json +1 -1
- package/src/assets/gen/comfyts-gen.ts +31 -14
- package/src/assets/gen/generate-roll.ts +20 -7
- package/src/assets/kit-config.ts +9 -0
- package/src/assets/kits/identity-kit.ts +7 -1
- package/src/assets/run-assets.ts +3 -3
- package/src/assets/server/studio-server.ts +3 -3
- 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 +6 -5
- 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/collections/providers.ts +1 -1
- 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 +32 -10
- package/src/core/config-rewrite.ts +27 -6
- package/src/core/discover-checks.ts +64 -15
- package/src/core/fix-scope.ts +5 -0
- package/src/core/floor-resolve.ts +18 -8
- package/src/core/git-root.ts +22 -12
- 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 +5 -3
- 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 +23 -16
- 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 +36 -6
- 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/git-bootstrap.ts +1 -1
- package/src/home/attach.ts +3 -5
- package/src/home/gui-app/ShipkitGui.swift +96 -0
- package/src/home/insights.ts +6 -1
- package/src/home/pm-config.ts +2 -2
- package/src/home/resources.ts +3 -0
- 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/plan/planner.ts +21 -4
- package/src/plan/repo-source.ts +7 -3
- package/src/problems/extract.ts +27 -5
- package/src/problems/problem-line.ts +2 -3
- package/src/release/run-release.ts +30 -16
- package/src/release/unit-gate.ts +12 -5
- package/src/repo-config.ts +51 -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 +3 -5
- package/src/serve/project-gutter.ts +5 -2
- package/src/serve/run-lanes.ts +7 -3
- package/src/serve/serve-hub.ts +1 -16
- package/src/serve/tracked-watch.ts +1 -1
- package/src/smoke/run-smoke.ts +13 -6
- package/src/start-repo.ts +15 -3
- package/src/suite/judge-suites.ts +81 -32
- package/src/suite/select.ts +2 -0
- package/src/suite/suite-cli.ts +27 -16
- 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/ts/typecheck-cache.ts +11 -0
- package/src/ts/typecheck-cli.ts +2 -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
|
@@ -1,26 +1,24 @@
|
|
|
1
|
-
// SK017 vscode hub task (build/vscode-tasks.md):
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
|
7
|
-
// task, a wrapper
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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,
|
|
54
|
-
* one line per thing it deliberately left
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
const
|
|
127
|
-
const
|
|
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
|
|
130
|
-
if (
|
|
131
|
-
|
|
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(
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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: '
|
|
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:
|
|
194
|
+
runs: { onPreCommit: false, onCi: true, onFull: true, onRelease: false, onHealth: true, onSave: 'moved', onClock: false },
|
|
193
195
|
cache: { by: 'reads' },
|
|
194
|
-
fix: '
|
|
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
|
|
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}:
|
|
206
|
-
for (const ch of r.changes) c.fail(`${TASKS}: ${ch.replace(/^removed/, 'should remove').replace(/^dropped/, 'should drop')
|
|
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.
|
|
215
|
-
|
|
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 (
|
|
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)
|
|
9
|
-
// `ruleExempt`
|
|
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
|
|
23
|
-
* they become. defineRepo already refused every `when` word but
|
|
24
|
-
|
|
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
|
|
29
|
-
|
|
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 (
|
|
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',
|
|
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]),
|
package/src/cli/cmd_Commands.ts
CHANGED
|
@@ -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
|
|
93
|
-
['
|
|
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)
|