rvlib-shipkit 1.9.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/package.json +1 -1
- package/src/check.ts +5 -2
- package/src/checks/SK001-package-json-shape.check.ts +47 -12
- package/src/checks/SK013-one-way-in-policy.check.ts +8 -7
- package/src/checks/SK017-vscode-hub-task.check.ts +109 -106
- package/src/checks/SK019-tests-run-in-units.check.ts +2 -1
- package/src/checks/SK027-agent-wiki-links.check.ts +5 -2
- package/src/checks/SK029-explicit-check-files.check.ts +25 -8
- package/src/checks/SK034-commit-gates-armed.check.ts +90 -0
- package/src/cli/cmd_Ci.ts +3 -3
- package/src/cli/cmd_Commands.ts +21 -5
- package/src/cli/cmd_Hooks.ts +6 -2
- package/src/cli/cmd_Init.ts +1 -1
- package/src/cli/cmd_Serve.ts +1 -2
- package/src/cli/cmd_Start.ts +1 -1
- package/src/cli.ts +2 -4
- package/src/core/budget.ts +6 -8
- package/src/core/bun-builtins.ts +36 -0
- package/src/core/bun-tool-cli.ts +23 -0
- package/src/core/canonical-scripts.ts +6 -4
- package/src/core/check-ctx.ts +104 -10
- package/src/core/check-file-names.ts +2 -3
- package/src/core/check-pass.ts +17 -1
- package/src/core/check-runner.ts +19 -6
- package/src/core/config-rewrite.ts +27 -6
- package/src/core/discover-checks.ts +30 -10
- package/src/core/floor-resolve.ts +16 -7
- package/src/core/job-run.ts +2 -2
- package/src/core/layout.ts +4 -1
- package/src/core/legacy-shapes.ts +40 -0
- package/src/core/one-lib.ts +1 -1
- package/src/core/one-way-in-policy.ts +106 -13
- package/src/core/pre-commit-discovery.ts +106 -0
- package/src/core/retired-checks.ts +2 -0
- package/src/core/run-ci.ts +16 -10
- package/src/core/run-fix.ts +9 -0
- package/src/core/run-fixers.ts +91 -35
- package/src/core/runs-matrix.ts +7 -0
- package/src/core/slots.ts +1 -1
- package/src/core/vendored-read.ts +39 -0
- package/src/core/vendored.ts +82 -0
- package/src/core/waivers.ts +125 -0
- package/src/core/when-block.ts +25 -185
- package/src/core/when.ts +30 -13
- package/src/core/wire.ts +17 -1
- package/src/fleet/update-fleet.ts +11 -2
- package/src/git-bootstrap.ts +1 -1
- package/src/home/attach.ts +3 -5
- package/src/home/insights.ts +6 -1
- package/src/home/pm-config.ts +2 -2
- package/src/home/run-daemon.ts +2 -2
- package/src/home/run-unit-action.ts +2 -2
- package/src/hooks/commit-message.ts +2 -14
- package/src/hooks/hook-gates.ts +38 -0
- package/src/hooks/leak-guard.ts +8 -3
- package/src/hooks/one-way-in-guard.ts +8 -2
- package/src/hooks/root-identity.ts +9 -6
- package/src/hooks/run-hooks.ts +72 -41
- package/src/hooks/staged.ts +49 -0
- package/src/hooks/tree-shrink.ts +41 -0
- package/src/init-repo.ts +0 -42
- package/src/ledger/check-table.ts +1 -1
- package/src/ledger/gate-checks.ts +45 -9
- package/src/ledger/ledger.ts +6 -1
- package/src/ledger/repo-view.ts +14 -4
- package/src/ledger/toolchain-identity.ts +4 -2
- package/src/mv/mv-md.ts +6 -1
- package/src/problems/extract.ts +27 -5
- package/src/problems/problem-line.ts +2 -3
- package/src/release/run-release.ts +13 -8
- package/src/repo-config.ts +44 -27
- package/src/scaffold/files/bunfig.toml +11 -0
- package/src/scaffold/files/vscode-settings.json +1 -2
- package/src/scaffold/jsonc-spans.ts +16 -0
- package/src/scaffold/project-scaffold.ts +43 -77
- package/src/serve/hub-app/check-view.tsx +4 -4
- package/src/serve/hub-app/check-words.ts +1 -0
- package/src/serve/hub-app/checks-svc.ts +1 -2
- package/src/serve/hub-app/home.html +0 -1
- package/src/serve/hub-app/hub-types.ts +0 -2
- package/src/serve/hub-app/index.html +0 -1
- package/src/serve/hub-app/project-list.css +1 -2
- package/src/serve/hub-app/tree-build.ts +3 -2
- package/src/serve/hub-app/tree-svc.ts +2 -2
- package/src/serve/hub-port.ts +3 -3
- package/src/serve/hub-runtime.ts +0 -3
- package/src/serve/serve-hub.ts +1 -16
- package/src/serve/tracked-watch.ts +1 -1
- package/src/start-repo.ts +15 -3
- package/src/suite/judge-suites.ts +29 -28
- package/src/suite/select.ts +2 -0
- package/src/suite/suite-cli.ts +6 -3
- package/src/suite/suite-cpu-preload.ts +7 -11
- package/src/suite/test-units.ts +7 -4
- package/src/ts/config.ts +12 -5
- package/src/ts/imports.ts +3 -1
- package/src/tui/build-steps-panel.ts +1 -1
- package/src/wiki/scan-md.ts +16 -9
- package/src/checks/SK009-commit-ai-tells.check.ts +0 -131
- package/src/checks/SK014-prose-ste.check.ts +0 -78
- package/src/cli/cmd_Prose.ts +0 -74
- package/src/prose/prose-view.ts +0 -90
- package/src/prose/run-prose.ts +0 -187
- package/src/prose/ste.ts +0 -552
- package/src/scaffold/files/vscode-tasks.json +0 -24
- package/src/serve/hub-app/prose-hits-st.ts +0 -125
- package/src/serve/hub-app/prose-hits-view.tsx +0 -176
- package/src/serve/hub-app/prose-hits.css +0 -21
|
@@ -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,14 +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
23
|
['one check + its dep closure, the tree left alone', 'sk ci --only typecheck'],
|
|
24
|
-
['one check,
|
|
24
|
+
['one check, its own repair first', 'sk ci --only SK004 --fix'],
|
|
25
25
|
['every check declaring one group', 'sk ci --only wiki'],
|
|
26
26
|
['run everything, take nothing from the cache', 'sk ci --fresh'],
|
|
27
27
|
['include an opt in check', 'sk ci --with up:itch'],
|
|
@@ -32,7 +32,7 @@ export class CiCommand extends Command {
|
|
|
32
32
|
|
|
33
33
|
bail = Option.Boolean('--bail', false, { description: 'stop at the first failure' })
|
|
34
34
|
json = Option.Boolean('--json', false, { description: 'machine-readable result instead of wire lines' })
|
|
35
|
-
fix = Option.Boolean('--fix', { description: 'repair
|
|
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' })
|
|
36
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' })
|
|
37
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' })
|
|
38
38
|
skip = Option.Array('--skip', [], { description: 'skip a check group (repeatable)' })
|
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)
|
package/src/cli/cmd_Hooks.ts
CHANGED
|
@@ -4,9 +4,13 @@ import { Command, Option } from 'clipanion'
|
|
|
4
4
|
import { hookBinInvocation, installHooks, listGates, preCommitChecks, runCommitMsg, runPreCommit, type GateOutcome } from '../hooks/run-hooks.ts'
|
|
5
5
|
import { loadRepoConfig } from '../repo-config.ts'
|
|
6
6
|
|
|
7
|
+
// a gate that passed prints only what still needs a reader (a WARN, a waived
|
|
8
|
+
// hit), so a clean commit costs one line, not one per gate
|
|
7
9
|
const report = (out: GateOutcome[], write: (s: string) => void): number => {
|
|
8
|
-
for (const g of out) for (const l of g.lines) write(`${l}\n`)
|
|
9
|
-
|
|
10
|
+
for (const g of out) for (const l of g.lines) if (!g.ok || !l.startsWith('ok: ')) write(`${l}\n`)
|
|
11
|
+
const ok = out.every((g) => g.ok)
|
|
12
|
+
if (ok) write(`ok: ${out.length} commit gate(s) clean\n`)
|
|
13
|
+
return ok ? 0 : 1
|
|
10
14
|
}
|
|
11
15
|
|
|
12
16
|
export class HooksInstallCommand extends Command {
|
package/src/cli/cmd_Init.ts
CHANGED
|
@@ -8,7 +8,7 @@ export class InitCommand extends Command {
|
|
|
8
8
|
|
|
9
9
|
static override usage = Command.Usage({
|
|
10
10
|
category: 'Setup',
|
|
11
|
-
description: 'wire
|
|
11
|
+
description: 'wire this dir as a shipkit repo, like git init: a private github repo and a push unless --no-github',
|
|
12
12
|
details: 'wire THE CURRENT DIRECTORY as a shipkit repo, like git init: cwd only, never creates a project dir. scaffolds repo.config.ts + ci/check/contracts package.json scripts + the create-only .vscode/ that starts the watchers and the hub on folder open, prints how the floor resolved, and runs the bootstrap chain around it: git init, then first commit + PRIVATE github repo + push once the scaffold is in. Idempotent on an already-wired repo. To create or adopt ~/dev/<name> from anywhere: `shipkit start <name>`',
|
|
13
13
|
examples: [
|
|
14
14
|
['wire the repo you are standing in', 'sk init'],
|
package/src/cli/cmd_Serve.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
// shipkit serve — the ONE hub server per repo (
|
|
2
|
-
// clickable URLs). Thin clipanion surface; logic in serve/run-serve.ts.
|
|
1
|
+
// shipkit serve — the ONE hub server per repo (clickable URLs). Thin clipanion surface; logic in serve/run-serve.ts.
|
|
3
2
|
import { Command, Option } from 'clipanion'
|
|
4
3
|
|
|
5
4
|
export class ServeCommand extends Command {
|
package/src/cli/cmd_Start.ts
CHANGED
|
@@ -9,7 +9,7 @@ export class StartCommand extends Command {
|
|
|
9
9
|
|
|
10
10
|
static override usage = Command.Usage({
|
|
11
11
|
category: 'Setup',
|
|
12
|
-
description: 'create or enter ~/dev/<name
|
|
12
|
+
description: 'create or enter ~/dev/<name>, ready to work in: a private github repo and a push unless --no-github',
|
|
13
13
|
details: 'create (or enter) ~/dev/<name> from ANY cwd, READY to work in. On any dir with no commit history it writes the day-one shape (package.json + src/index.ts + lint/fmt/typecheck, tsconfig, .vscode tasks + settings, the verify skill, an agent/ wiki, .shipkit/reflections), installs the toolchain, wires shipkit (repo.config.ts via init), and runs the whole bootstrap chain: git init, first commit, PRIVATE github repo, push. Loose files ride in that first commit, and only a repo with real history is left as it is. Writes are create-only for files, additive for package.json scripts and .gitignore. CLAUDE.md is the two-line house template on its own (HOUSE_INCLUDE in src/start-repo.ts: the base wiki include, then `@./agent/index.md`, and --include overrides it), applied only on a machine where the house index exists, and the agent route lands last whenever the repo has agent/index.md. The house lines also opt the repo into the .claude/ policy files (a pre-approved shell allowlist + the attribution silencer), so a start with no house widens no agent permissions. Prints the dir on stdout so a shell alias can cd there. cwd-only sibling: `shipkit init`',
|
|
14
14
|
examples: [
|
|
15
15
|
['create or enter ~/dev/pong: dir, package.json, shipkit wiring, git, first commit, private github repo, push, the two-line CLAUDE.md', 'sk start pong'],
|
package/src/cli.ts
CHANGED
|
@@ -26,7 +26,6 @@ import { KeysCommand } from './cli/cmd_Keys.ts'
|
|
|
26
26
|
import { MvCommand } from './cli/cmd_Mv.ts'
|
|
27
27
|
import { NeedsCommand } from './cli/cmd_Needs.ts'
|
|
28
28
|
import { PbCollectionCommand } from './cli/cmd_Pb.ts'
|
|
29
|
-
import { ProseCommand } from './cli/cmd_Prose.ts'
|
|
30
29
|
import { ReflectCommand } from './cli/cmd_Reflect.ts'
|
|
31
30
|
import { ReleaseCommand } from './cli/cmd_Release.ts'
|
|
32
31
|
import { ReleasesCommand } from './cli/cmd_Releases.ts'
|
|
@@ -91,7 +90,6 @@ export function buildCli(): Cli {
|
|
|
91
90
|
cli.register(NeedsCommand)
|
|
92
91
|
cli.register(FleetCommand)
|
|
93
92
|
cli.register(PbCollectionCommand)
|
|
94
|
-
cli.register(ProseCommand)
|
|
95
93
|
cli.register(SmokeCommand)
|
|
96
94
|
cli.register(ReflectCommand)
|
|
97
95
|
cli.register(KeysCommand)
|
|
@@ -125,8 +123,8 @@ if (import.meta.main) {
|
|
|
125
123
|
const argv = aliasArgs(process.argv.slice(2))
|
|
126
124
|
const { handoff } = await import('./home/handoff.ts')
|
|
127
125
|
handoff(argv)
|
|
128
|
-
const {
|
|
129
|
-
|
|
126
|
+
const { installOneLib } = await import('./core/one-lib.ts')
|
|
127
|
+
installOneLib()
|
|
130
128
|
const { autostart } = await import('./home/autostart.ts')
|
|
131
129
|
await autostart(argv)
|
|
132
130
|
void buildCli().runExit(argv)
|
package/src/core/budget.ts
CHANGED
|
@@ -6,16 +6,14 @@
|
|
|
6
6
|
import { availableParallelism, loadavg } from 'os'
|
|
7
7
|
import type { MachineLoad } from '../suite/judge-suites.ts'
|
|
8
8
|
|
|
9
|
-
/** an in process body, a rule or a page check
|
|
10
|
-
*
|
|
11
|
-
|
|
12
|
-
export const IN_PROCESS_BUDGET_MS = 2_000
|
|
9
|
+
/** an in process body, a rule or a page check: the house bar is under 1 s
|
|
10
|
+
* (identity/how-we-work.md, Velocity). The median rule answers in under 50ms */
|
|
11
|
+
export const IN_PROCESS_BUDGET_MS = 1_000
|
|
13
12
|
|
|
14
13
|
/** a subprocess (a build step, a test suite, a `cmd` check) or a world probe
|
|
15
|
-
* (a `ttl` check, or a `never:` one, whose answer lives outside the repo)
|
|
16
|
-
*
|
|
17
|
-
|
|
18
|
-
export const SUBPROCESS_BUDGET_MS = 10_000
|
|
14
|
+
* (a `ttl` check, or a `never:` one, whose answer lives outside the repo):
|
|
15
|
+
* the house ceiling is 5 s, and one over it is a defect to fix */
|
|
16
|
+
export const SUBPROCESS_BUDGET_MS = 5_000
|
|
19
17
|
|
|
20
18
|
/** what a check declares about itself that picks its default. `world`: it
|
|
21
19
|
* declares `ttl` or `never`, so it reads the world rather than the repo */
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// bun's own linter as the default lint slot, and bun's formatter as the
|
|
2
|
+
// manual `bun fmt` (never a gate step). A repo opts in by declaring the tool's
|
|
3
|
+
// section in bunfig.toml: the section is where its config lives.
|
|
4
|
+
//
|
|
5
|
+
// bun before 1.4.4 has neither command, and there `bun lint` falls through to
|
|
6
|
+
// the package.json `lint` script. A script that calls `bun lint` then calls
|
|
7
|
+
// itself forever, so an old bun gets a loud refusal instead of a spawn. The
|
|
8
|
+
// lint slot runs through core/bun-tool-cli.ts, which keeps a same-named script out.
|
|
9
|
+
import { fileURLToPath } from 'url'
|
|
10
|
+
|
|
11
|
+
export type BunTool = 'lint' | 'fmt'
|
|
12
|
+
|
|
13
|
+
export const BUN_TOOLS_SINCE = '1.4.4'
|
|
14
|
+
|
|
15
|
+
/** does the bun running shipkit ship `bun lint` and `bun fmt` */
|
|
16
|
+
export const bunHasTools = (version: string = Bun.version): boolean => Bun.semver.order(version.replace(/-.*$/, ''), BUN_TOOLS_SINCE) >= 0
|
|
17
|
+
|
|
18
|
+
const header = (tool: BunTool): RegExp => new RegExp(`^\\s*\\[${tool}(?:\\.[^\\]]+)?\\]\\s*(?:#.*)?$`)
|
|
19
|
+
|
|
20
|
+
/** does this bunfig.toml text declare `[lint]` / `[fmt]` (or a subtable of it) */
|
|
21
|
+
export function bunfigDeclares(bunfig: string | null, tool: BunTool): boolean {
|
|
22
|
+
return bunfig != null && bunfig.split('\n').some((l) => header(tool).test(l))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** why this bun cannot run the tool, or null when it can */
|
|
26
|
+
export function bunToolRefusal(tool: BunTool, version: string = Bun.version): string | null {
|
|
27
|
+
return bunHasTools(version) ? null : `bunfig.toml declares [${tool}] and bun ${version} has no \`bun ${tool}\` (it ships in ${BUN_TOOLS_SINCE}): run \`bun upgrade --canary\``
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** the argv the lint slot runs: the built-in, through the runner that keeps a
|
|
31
|
+
* same-named package.json script out of its way. The repo's vendored dirs
|
|
32
|
+
* are never walked */
|
|
33
|
+
export function bunLintCmd(vendored: readonly string[] = []): string[] {
|
|
34
|
+
const cli = fileURLToPath(import.meta.resolve('rvlib-shipkit/src/core/bun-tool-cli.ts'))
|
|
35
|
+
return [process.execPath, cli, 'lint', '--format', 'unix', ...vendored.map((d) => `--ignore-pattern=${d}/**`)]
|
|
36
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// runs `bun lint` as the BUILT-IN, whatever package.json holds.
|
|
3
|
+
// bun runs a script of the same name in its place, flags and all, unless the
|
|
4
|
+
// call says it is that script: `npm_lifecycle_event` names the tool, and
|
|
5
|
+
// `npm_package_json` is absent or names this manifest by its resolved path.
|
|
6
|
+
// A run started under another package's `bun run` inherits that package's
|
|
7
|
+
// path, so it is dropped here (measured, bun 1.4.4).
|
|
8
|
+
import { bunToolRefusal } from './bun-builtins.ts'
|
|
9
|
+
|
|
10
|
+
const [tool, ...args] = process.argv.slice(2)
|
|
11
|
+
if (tool !== 'lint') {
|
|
12
|
+
console.error(`bun-tool-cli: expected lint, got ${tool ?? 'nothing'}`)
|
|
13
|
+
process.exit(2)
|
|
14
|
+
}
|
|
15
|
+
const refused = bunToolRefusal(tool)
|
|
16
|
+
if (refused != null) {
|
|
17
|
+
console.error(refused)
|
|
18
|
+
process.exit(1)
|
|
19
|
+
}
|
|
20
|
+
const env: Record<string, string | undefined> = { ...process.env, npm_lifecycle_event: tool }
|
|
21
|
+
delete env.npm_package_json
|
|
22
|
+
const p = Bun.spawnSync([process.execPath, tool, ...args], { stdio: ['inherit', 'inherit', 'inherit'], env })
|
|
23
|
+
process.exit(p.exitCode ?? 1)
|
|
@@ -36,10 +36,12 @@ export function conditionalBodies(_opts: ScriptStyle = {}): Record<string, strin
|
|
|
36
36
|
// used to hold moves to `<name>:raw`, which is what shipkit itself runs and
|
|
37
37
|
// what an agent uses to iterate on one repro file.
|
|
38
38
|
//
|
|
39
|
-
// only these
|
|
40
|
-
// (nobody reaches for them by reflex)
|
|
41
|
-
//
|
|
42
|
-
|
|
39
|
+
// only these two. `imports:check` and `cycles` are shipkit's own vocabulary
|
|
40
|
+
// (nobody reaches for them by reflex), `fmt` is a manual tool no gate runs,
|
|
41
|
+
// and `lint` is
|
|
42
|
+
// `bun lint` itself: a `lint` script would shadow the built-in, which lints a
|
|
43
|
+
// whole repo in well under a second, with nothing for a cache to save.
|
|
44
|
+
export const ROUTED_SCRIPTS = ['test', 'typecheck'] as const
|
|
43
45
|
|
|
44
46
|
export const RAW_SUFFIX = ':raw'
|
|
45
47
|
|
package/src/core/check-ctx.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// CheckCtx — the ONE parameter a check body receives, and its only door to the
|
|
2
2
|
// repo. Four parts:
|
|
3
3
|
//
|
|
4
|
-
// report c.ok / note / warn / fail / unreachable, the wire lines (wire.ts)
|
|
4
|
+
// report c.ok / note / warn / fail / unreachable, the wire lines (wire.ts),
|
|
5
|
+
// a finding with its site, which an in-file directive can waive
|
|
5
6
|
// where c.root, c.config, c.frozen
|
|
6
7
|
// reads c.read / json / exists / isLink / list / entries, each recorded with what it saw, and
|
|
7
8
|
// c.use(scan) for a scan every check of the pass shares (scan.ts)
|
|
@@ -27,7 +28,12 @@ import { normalizeInput, WHOLE_TREE } from '../ledger/git-scan.ts'
|
|
|
27
28
|
import { shipkitRoot } from './git-root.ts'
|
|
28
29
|
import type { RepoConfig } from '../repo-config.ts'
|
|
29
30
|
import type { Scan } from '../scan.ts'
|
|
30
|
-
import type
|
|
31
|
+
import { siteText, type Line, type Site } from './wire.ts'
|
|
32
|
+
import { DIRECTIVE_MARK, parseDirectives, reaches, spell, type Directive } from './waivers.ts'
|
|
33
|
+
|
|
34
|
+
/** what `c.warn` and `c.fail` take after the message: the fix pointer alone,
|
|
35
|
+
* or the pointer and the site the finding sits at */
|
|
36
|
+
export type FindingMore = string | { fix?: string; at?: Site }
|
|
31
37
|
|
|
32
38
|
// thrown by t.fetch on 429: no answer, so the body runner (check.ts
|
|
33
39
|
// runCheckBody) reports it as unreachable, never as a FAIL. It knows it by
|
|
@@ -56,6 +62,16 @@ export class CheckCtx {
|
|
|
56
62
|
/** why the reads cannot describe this answer (it reached the world), null
|
|
57
63
|
* while they can */
|
|
58
64
|
untracked: string | null = null
|
|
65
|
+
/** the check this context reports for, set by the body runner. A
|
|
66
|
+
* directive waives a site only when it names this id */
|
|
67
|
+
checkId: string | null = null
|
|
68
|
+
/** the text of every repo file read here that carries a directive mark,
|
|
69
|
+
* by path: what a waiver and the unused directive scan parse */
|
|
70
|
+
readonly marked = new Map<string, string>()
|
|
71
|
+
private readonly parsed = new Map<string, Directive[]>()
|
|
72
|
+
private readonly siteFiles = new Set<string>()
|
|
73
|
+
private readonly waived: { text: string; at: Site; by: string; reason: string }[] = []
|
|
74
|
+
private readonly usedDirectives = new Set<string>()
|
|
59
75
|
|
|
60
76
|
constructor(
|
|
61
77
|
/** the runner pass this body runs in. Absent for a context that only
|
|
@@ -73,19 +89,94 @@ export class CheckCtx {
|
|
|
73
89
|
note(msg: string): void { this.lines.push({ kind: 'note', text: msg }) }
|
|
74
90
|
/** a WARN that came back from something we could not reach — no answer, so
|
|
75
91
|
* nothing to remember */
|
|
76
|
-
unreachable(msg: string,
|
|
92
|
+
unreachable(msg: string, more?: FindingMore): void {
|
|
77
93
|
this.unmeasured = true
|
|
78
|
-
this.warn(msg,
|
|
94
|
+
this.warn(msg, more)
|
|
79
95
|
}
|
|
80
|
-
|
|
96
|
+
/** a defect that does not stop you. `more` is the fix pointer, or
|
|
97
|
+
* `{ fix, at }` with the site, which a directive in that file can waive */
|
|
98
|
+
warn(msg: string, more?: FindingMore): void {
|
|
99
|
+
const f = this.finding(msg, more)
|
|
100
|
+
if (f == null) return
|
|
81
101
|
this.warned = true
|
|
82
|
-
this.lines.push({ kind: 'warn', text:
|
|
83
|
-
if (fix != null) this.lines.push({ kind: 'fix-warn', text: fix })
|
|
102
|
+
this.lines.push({ kind: 'warn', text: f.text, ...(f.at == null ? {} : { at: f.at }) })
|
|
103
|
+
if (f.fix != null) this.lines.push({ kind: 'fix-warn', text: f.fix })
|
|
84
104
|
}
|
|
85
|
-
|
|
105
|
+
/** broken. `more` is the fix pointer, or `{ fix, at }` with the site,
|
|
106
|
+
* which a directive in that file can waive */
|
|
107
|
+
fail(msg: string, more?: FindingMore): void {
|
|
108
|
+
const f = this.finding(msg, more)
|
|
109
|
+
if (f == null) return
|
|
86
110
|
this.failed = true
|
|
87
|
-
this.lines.push({ kind: 'fail', text:
|
|
88
|
-
if (fix != null) this.lines.push({ kind: 'fix-fail', text: fix })
|
|
111
|
+
this.lines.push({ kind: 'fail', text: f.text, ...(f.at == null ? {} : { at: f.at }) })
|
|
112
|
+
if (f.fix != null) this.lines.push({ kind: 'fix-fail', text: f.fix })
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── waivers (core/waivers.ts owns the grammar) ────────────────────────
|
|
116
|
+
/** the finding as it prints, or null when a directive waived its site.
|
|
117
|
+
* A message that does not name its file gets the site in front */
|
|
118
|
+
private finding(msg: string, more: FindingMore | undefined): { text: string; fix?: string; at?: Site } | null {
|
|
119
|
+
const { fix, at } = typeof more === 'object' ? more : { fix: more, at: undefined }
|
|
120
|
+
if (at == null) return { text: msg, fix }
|
|
121
|
+
const text = msg.includes(at.file) ? msg : `${siteText(at)}: ${msg}`
|
|
122
|
+
const by = this.waiverFor(at)
|
|
123
|
+
if (by == null) return { text, fix, at }
|
|
124
|
+
this.waived.push({ text, at, by: `${at.file}:${by.line}`, reason: by.reason ?? '' })
|
|
125
|
+
return null
|
|
126
|
+
}
|
|
127
|
+
/** the directive that waives this site for this check. The file is read
|
|
128
|
+
* through `c`, so it joins the answer's key: a directive added or
|
|
129
|
+
* removed afterwards asks the question again */
|
|
130
|
+
private waiverFor(at: Site): Directive | null {
|
|
131
|
+
if (this.checkId == null || this.pass == null || isAbsolute(at.file)) return null
|
|
132
|
+
let rel: string
|
|
133
|
+
try { rel = this.rel(at.file) } catch { return null }
|
|
134
|
+
this.siteFiles.add(rel)
|
|
135
|
+
this.read(rel)
|
|
136
|
+
for (const d of this.directivesIn(rel)) {
|
|
137
|
+
if (d.reason == null || !d.ids.includes(this.checkId) || !reaches(d, at.line)) continue
|
|
138
|
+
this.usedDirectives.add(`${rel}:${d.line}`)
|
|
139
|
+
return d
|
|
140
|
+
}
|
|
141
|
+
return null
|
|
142
|
+
}
|
|
143
|
+
private directivesIn(file: string): Directive[] {
|
|
144
|
+
const text = this.marked.get(file)
|
|
145
|
+
if (text == null) return []
|
|
146
|
+
let ds = this.parsed.get(file)
|
|
147
|
+
if (ds == null) this.parsed.set(file, (ds = this.pass?.directives(file, text) ?? parseDirectives(file, text)))
|
|
148
|
+
return ds
|
|
149
|
+
}
|
|
150
|
+
/** after the body, once: count the waived sites, and WARN on every
|
|
151
|
+
* directive naming this check, in a file it read, that waived nothing
|
|
152
|
+
* or gave no reason. A directive for a retired id, or in a file no
|
|
153
|
+
* check reads any more, is nobody's to report */
|
|
154
|
+
settleWaivers(): void {
|
|
155
|
+
const id = this.checkId
|
|
156
|
+
if (id == null) return
|
|
157
|
+
const loud = (at: Site, text: string, fix: string): void => {
|
|
158
|
+
this.warned = true
|
|
159
|
+
this.lines.push({ kind: 'warn', text: `${siteText(at)}: ${text}`, at }, { kind: 'fix-warn', text: fix })
|
|
160
|
+
}
|
|
161
|
+
// a site, not a finding: one line can hold several findings
|
|
162
|
+
const n = new Set(this.waived.map((w) => siteText(w.at))).size
|
|
163
|
+
if (n > 0) this.lines.push({ kind: 'skip', text: `${n === 1 ? '1 site waived in its file' : `${n} sites waived in their files`} (--verbose lists them)`, waiver: 'count' })
|
|
164
|
+
for (const w of this.waived) this.lines.push({ kind: 'skip', text: `waived by ${w.by} (${w.reason}): ${w.text}`, at: w.at, waiver: 'site' })
|
|
165
|
+
for (const file of this.marked.keys())
|
|
166
|
+
for (const d of this.directivesIn(file)) {
|
|
167
|
+
if (!d.ids.includes(id)) continue
|
|
168
|
+
const at = { file, line: d.line }
|
|
169
|
+
if (d.reason == null) loud(at, `\`${spell(d, id)}\` gives no reason after \` -- \`, so it waives nothing`, `say why the site is fine: \`${spell(d, id)} -- <reason>\``)
|
|
170
|
+
else if (!this.usedDirectives.has(`${file}:${d.line}`))
|
|
171
|
+
loud(at, `\`${spell(d, id)}\` waives nothing ${d.scope === 'file' ? 'in this file' : `on line ${d.scope === 'line' ? d.line : d.line + 1}`}`, 'delete the directive, or move it onto the site it was written for')
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/** every file whose directives this answer depends on: the files of its
|
|
175
|
+
* findings, and the files carrying a directive that names it */
|
|
176
|
+
waiverFiles(): string[] {
|
|
177
|
+
const id = this.checkId
|
|
178
|
+
const named = id == null ? [] : [...this.marked.keys()].filter((f) => this.directivesIn(f).some((d) => d.ids.includes(id)))
|
|
179
|
+
return [...new Set([...this.siteFiles, ...named])].sort()
|
|
89
180
|
}
|
|
90
181
|
|
|
91
182
|
// ── where ─────────────────────────────────────────────────────────────
|
|
@@ -99,6 +190,7 @@ export class CheckCtx {
|
|
|
99
190
|
* it and none that did not */
|
|
100
191
|
get config(): RepoConfig {
|
|
101
192
|
const p = this.repo('config')
|
|
193
|
+
if (!p.recordsConfig) return p.config
|
|
102
194
|
const deps = p.configReads()
|
|
103
195
|
for (const [f, oid] of deps.files) this.reads.file(f, oid)
|
|
104
196
|
if (deps.untracked != null && this.untracked == null) {
|
|
@@ -150,6 +242,7 @@ export class CheckCtx {
|
|
|
150
242
|
return null
|
|
151
243
|
}
|
|
152
244
|
this.reads.file(p, blobOid(bytes))
|
|
245
|
+
if (!isAbsolute(p) && bytes.includes(DIRECTIVE_MARK)) this.marked.set(p, bytes.toString('utf8'))
|
|
153
246
|
return bytes
|
|
154
247
|
}
|
|
155
248
|
/** a repo-relative json file, parsed. null when absent, and a parse error
|
|
@@ -206,6 +299,7 @@ export class CheckCtx {
|
|
|
206
299
|
const s = pass.scan(scan, () => new CheckCtx(pass))
|
|
207
300
|
const value = await s.value
|
|
208
301
|
this.reads.merge(s.ctx.reads)
|
|
302
|
+
for (const [f, text] of s.ctx.marked) this.marked.set(f, text)
|
|
209
303
|
if (s.ctx.untracked != null) this.untrack(s.ctx.untracked)
|
|
210
304
|
return value
|
|
211
305
|
}
|