rvlib-shipkit 1.8.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/package.json +1 -1
  3. package/src/assets/gen/comfyts-gen.ts +31 -14
  4. package/src/assets/gen/generate-roll.ts +20 -7
  5. package/src/assets/kit-config.ts +9 -0
  6. package/src/assets/kits/identity-kit.ts +7 -1
  7. package/src/assets/run-assets.ts +3 -3
  8. package/src/assets/server/studio-server.ts +3 -3
  9. package/src/check.ts +5 -2
  10. package/src/checks/SK001-package-json-shape.check.ts +47 -12
  11. package/src/checks/SK013-one-way-in-policy.check.ts +8 -7
  12. package/src/checks/SK017-vscode-hub-task.check.ts +109 -106
  13. package/src/checks/SK019-tests-run-in-units.check.ts +2 -1
  14. package/src/checks/SK027-agent-wiki-links.check.ts +5 -2
  15. package/src/checks/SK029-explicit-check-files.check.ts +25 -8
  16. package/src/checks/SK034-commit-gates-armed.check.ts +90 -0
  17. package/src/cli/cmd_Ci.ts +6 -5
  18. package/src/cli/cmd_Commands.ts +21 -5
  19. package/src/cli/cmd_Hooks.ts +6 -2
  20. package/src/cli/cmd_Init.ts +1 -1
  21. package/src/cli/cmd_Serve.ts +1 -2
  22. package/src/cli/cmd_Start.ts +1 -1
  23. package/src/cli.ts +2 -4
  24. package/src/collections/providers.ts +1 -1
  25. package/src/core/budget.ts +6 -8
  26. package/src/core/bun-builtins.ts +36 -0
  27. package/src/core/bun-tool-cli.ts +23 -0
  28. package/src/core/canonical-scripts.ts +6 -4
  29. package/src/core/check-ctx.ts +104 -10
  30. package/src/core/check-file-names.ts +2 -3
  31. package/src/core/check-pass.ts +17 -1
  32. package/src/core/check-runner.ts +32 -10
  33. package/src/core/config-rewrite.ts +27 -6
  34. package/src/core/discover-checks.ts +64 -15
  35. package/src/core/fix-scope.ts +5 -0
  36. package/src/core/floor-resolve.ts +18 -8
  37. package/src/core/git-root.ts +22 -12
  38. package/src/core/job-run.ts +2 -2
  39. package/src/core/layout.ts +4 -1
  40. package/src/core/legacy-shapes.ts +40 -0
  41. package/src/core/one-lib.ts +5 -3
  42. package/src/core/one-way-in-policy.ts +106 -13
  43. package/src/core/pre-commit-discovery.ts +106 -0
  44. package/src/core/retired-checks.ts +2 -0
  45. package/src/core/run-ci.ts +23 -16
  46. package/src/core/run-fix.ts +9 -0
  47. package/src/core/run-fixers.ts +91 -35
  48. package/src/core/runs-matrix.ts +7 -0
  49. package/src/core/slots.ts +36 -6
  50. package/src/core/vendored-read.ts +39 -0
  51. package/src/core/vendored.ts +82 -0
  52. package/src/core/waivers.ts +125 -0
  53. package/src/core/when-block.ts +25 -185
  54. package/src/core/when.ts +30 -13
  55. package/src/core/wire.ts +17 -1
  56. package/src/git-bootstrap.ts +1 -1
  57. package/src/home/attach.ts +3 -5
  58. package/src/home/gui-app/ShipkitGui.swift +96 -0
  59. package/src/home/insights.ts +6 -1
  60. package/src/home/pm-config.ts +2 -2
  61. package/src/home/resources.ts +3 -0
  62. package/src/home/run-daemon.ts +2 -2
  63. package/src/home/run-unit-action.ts +2 -2
  64. package/src/hooks/commit-message.ts +2 -14
  65. package/src/hooks/hook-gates.ts +38 -0
  66. package/src/hooks/leak-guard.ts +8 -3
  67. package/src/hooks/one-way-in-guard.ts +8 -2
  68. package/src/hooks/root-identity.ts +9 -6
  69. package/src/hooks/run-hooks.ts +72 -41
  70. package/src/hooks/staged.ts +49 -0
  71. package/src/hooks/tree-shrink.ts +41 -0
  72. package/src/init-repo.ts +0 -42
  73. package/src/ledger/check-table.ts +1 -1
  74. package/src/ledger/gate-checks.ts +45 -9
  75. package/src/ledger/ledger.ts +6 -1
  76. package/src/ledger/repo-view.ts +14 -4
  77. package/src/ledger/toolchain-identity.ts +4 -2
  78. package/src/mv/mv-md.ts +6 -1
  79. package/src/plan/planner.ts +21 -4
  80. package/src/plan/repo-source.ts +7 -3
  81. package/src/problems/extract.ts +27 -5
  82. package/src/problems/problem-line.ts +2 -3
  83. package/src/release/run-release.ts +30 -16
  84. package/src/release/unit-gate.ts +12 -5
  85. package/src/repo-config.ts +51 -27
  86. package/src/scaffold/files/bunfig.toml +11 -0
  87. package/src/scaffold/files/vscode-settings.json +1 -2
  88. package/src/scaffold/jsonc-spans.ts +16 -0
  89. package/src/scaffold/project-scaffold.ts +43 -77
  90. package/src/serve/hub-app/check-view.tsx +4 -4
  91. package/src/serve/hub-app/check-words.ts +1 -0
  92. package/src/serve/hub-app/checks-svc.ts +1 -2
  93. package/src/serve/hub-app/home.html +0 -1
  94. package/src/serve/hub-app/hub-types.ts +0 -2
  95. package/src/serve/hub-app/index.html +0 -1
  96. package/src/serve/hub-app/project-list.css +1 -2
  97. package/src/serve/hub-app/tree-build.ts +3 -2
  98. package/src/serve/hub-app/tree-svc.ts +2 -2
  99. package/src/serve/hub-port.ts +3 -3
  100. package/src/serve/hub-runtime.ts +3 -5
  101. package/src/serve/project-gutter.ts +5 -2
  102. package/src/serve/run-lanes.ts +7 -3
  103. package/src/serve/serve-hub.ts +1 -16
  104. package/src/serve/tracked-watch.ts +1 -1
  105. package/src/smoke/run-smoke.ts +13 -6
  106. package/src/start-repo.ts +15 -3
  107. package/src/suite/judge-suites.ts +81 -32
  108. package/src/suite/select.ts +2 -0
  109. package/src/suite/suite-cli.ts +27 -16
  110. package/src/suite/suite-cpu-preload.ts +7 -11
  111. package/src/suite/test-units.ts +7 -4
  112. package/src/ts/config.ts +12 -5
  113. package/src/ts/imports.ts +3 -1
  114. package/src/ts/typecheck-cache.ts +11 -0
  115. package/src/ts/typecheck-cli.ts +2 -1
  116. package/src/tui/build-steps-panel.ts +1 -1
  117. package/src/wiki/scan-md.ts +16 -9
  118. package/src/checks/SK009-commit-ai-tells.check.ts +0 -131
  119. package/src/checks/SK014-prose-ste.check.ts +0 -78
  120. package/src/cli/cmd_Prose.ts +0 -74
  121. package/src/prose/prose-view.ts +0 -90
  122. package/src/prose/run-prose.ts +0 -187
  123. package/src/prose/ste.ts +0 -552
  124. package/src/scaffold/files/vscode-tasks.json +0 -24
  125. package/src/serve/hub-app/prose-hits-st.ts +0 -125
  126. package/src/serve/hub-app/prose-hits-view.tsx +0 -176
  127. package/src/serve/hub-app/prose-hits.css +0 -21
@@ -0,0 +1,82 @@
1
+ // `vendored` in repo.config.ts: repo-relative dirs of THIRD PARTY code (a git
2
+ // subtree, a copied library) that no shipkit tool lints, formats, rewrites or
3
+ // analyses as the repo's own source. Read as TEXT, never by loading the
4
+ // config: the source-graph cli, the md scan and the file listings run where
5
+ // the config is never loaded, and a prefix test per path is the whole cost.
6
+ // loadRepoConfig refuses a config whose loaded value and text disagree.
7
+ // Pure, so a check's closure can import it: the disk half is vendored-read.ts.
8
+ //
9
+ // the one constraint a reader breaks first: cache keys and watchers still
10
+ // read vendored files. Vendored code is still an INPUT to builds and tests;
11
+ // only the tools that treat the tree as the repo's own source omit it.
12
+ import { configDepths } from './identity-color.ts'
13
+
14
+ const CONFIG_FILE = 'repo.config.ts'
15
+
16
+ const KEY_RE = /(?:^|[{,])\s*(['"]?)vendored\1\s*:\s*/gm
17
+
18
+ /** an entry as every tool compares it: posix, no `./`, no trailing `/` */
19
+ export const normalizeVendored = (d: string): string => d.replace(/\\/g, '/').replace(/^(\.\/)+/, '').replace(/\/+$/, '')
20
+
21
+ /** why an entry cannot be a vendored dir, null when it can */
22
+ export function vendoredProblem(entry: unknown): string | null {
23
+ if (typeof entry !== 'string' || entry.trim() === '') return 'is not a repo-relative dir path'
24
+ const d = normalizeVendored(entry)
25
+ if (d === '' || d === '.') return 'is the repo root: it would omit the whole repo from every tool'
26
+ if (entry.startsWith('/') || d.split('/').includes('..')) return 'leaves the repo: name a dir inside it'
27
+ if (/[*?[\]{}!]/.test(d)) return 'is a glob: name the dir itself, every path under it is omitted'
28
+ return null
29
+ }
30
+
31
+ /** the `vendored` entries the config text declares, normalized. Empty when
32
+ * the key is absent. Throws when the value is anything but a literal array
33
+ * of string literals, which a reader of the text cannot evaluate */
34
+ export function vendoredOfText(text: string): string[] {
35
+ const depth = configDepths(text)
36
+ const found: string[][] = []
37
+ for (const m of text.matchAll(KEY_RE)) {
38
+ const sep = /^[{,]/.test(m[0]) ? m.index : m.index - 1
39
+ const colon = m.index + m[0].lastIndexOf(':')
40
+ if (sep < 0 || depth[sep] !== 1 || depth[colon] !== 1) continue
41
+ const open = m.index + m[0].length
42
+ if (text[open] !== '[') throw new Error(`${CONFIG_FILE}: \`vendored\` must be a literal array of strings, the tools that never load the config read it as text`)
43
+ let close = open + 1
44
+ while (close < text.length && !(text[close] === ']' && depth[close] === 1)) close++
45
+ if (close >= text.length) throw new Error(`${CONFIG_FILE}: \`vendored\` has no closing \`]\``)
46
+ const body = text.slice(open + 1, close).replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '')
47
+ const items: string[] = []
48
+ const rest = body.replace(/(['"])((?:\\.|(?!\1).)*)\1/g, (_, _q: string, s: string) => { items.push(s); return '' })
49
+ if (!/^[\s,]*$/.test(rest)) throw new Error(`${CONFIG_FILE}: \`vendored\` must list string literals only, the tools that never load the config read it as text`)
50
+ found.push(items.map(normalizeVendored))
51
+ }
52
+ if (found.length > 1) throw new Error(`${CONFIG_FILE} declares \`vendored\` more than once`)
53
+ return found[0] ?? []
54
+ }
55
+
56
+ /** is this repo-relative path a vendored dir or inside one */
57
+ export function isVendored(rel: string, dirs: readonly string[]): boolean {
58
+ if (dirs.length === 0) return false
59
+ const n = rel.replace(/\\/g, '/').replace(/^(\.\/)+/, '')
60
+ return dirs.some((d) => n === d || n.startsWith(`${d}/`))
61
+ }
62
+
63
+ /** the glob's fixed leading dirs, before its first wildcard */
64
+ const globBase = (glob: string): string => {
65
+ const segs = glob.replace(/^(\.\/)+/, '').split('/')
66
+ const fixed: string[] = []
67
+ for (const s of segs.slice(0, -1)) {
68
+ if (/[*?[\]{}!]/.test(s)) break
69
+ fixed.push(s)
70
+ }
71
+ return fixed.join('/')
72
+ }
73
+
74
+ /** a listing with the vendored files left out, unless the glob itself names a
75
+ * path inside a vendored dir: a check that asks for `vendor/lib/src/**`
76
+ * wants exactly those files */
77
+ export function listingWithoutVendored(files: readonly string[], glob: string, dirs: readonly string[]): string[] {
78
+ if (dirs.length === 0) return [...files]
79
+ const base = globBase(glob)
80
+ if (base !== '' && isVendored(base, dirs)) return [...files]
81
+ return files.filter((f) => !isVendored(f, dirs))
82
+ }
@@ -0,0 +1,125 @@
1
+ // the in-file waiver grammar: a site a check reports can be waived where it
2
+ // stands, in whatever comment the file type has, and only with a reason.
3
+ //
4
+ // sk-disable-next-line <ID>[, <ID>…] -- <reason> the line below it
5
+ // sk-disable-line <ID>[, <ID>…] -- <reason> its own line
6
+ // sk-disable-file <ID>[, <ID>…] -- <reason> every site of the file
7
+ //
8
+ // carriers: `<!-- … -->` in markdown and html (fenced and inline code left
9
+ // out, so a page can show the grammar), `//` and `/* … */` in ts and js, `#`
10
+ // in sh, yaml, toml and python, all of them in an unknown type. A directive
11
+ // inside a string literal still counts: a test builds its samples at runtime.
12
+ // core/check-ctx.ts applies them, through `c`, so the waiving file is read
13
+ // into the check's key.
14
+
15
+ /** the token a file must contain before it is parsed at all */
16
+ export const DIRECTIVE_MARK = 'sk-disable-'
17
+
18
+ export type DirectiveScope = 'next-line' | 'line' | 'file'
19
+
20
+ export type Directive = {
21
+ scope: DirectiveScope
22
+ /** the check ids it names */
23
+ ids: string[]
24
+ /** null when the directive gave none, which waives nothing */
25
+ reason: string | null
26
+ /** 1-based line of the directive itself */
27
+ line: number
28
+ }
29
+
30
+ type Carrier = 'html' | 'slash' | 'block' | 'hash'
31
+
32
+ const MARKDOWN = new Set(['md', 'mdx', 'markdown'])
33
+ const HTML = new Set(['html', 'htm', 'xml', 'svg', 'vue'])
34
+ const C_LIKE = new Set(['ts', 'tsx', 'mts', 'cts', 'js', 'jsx', 'mjs', 'cjs', 'css', 'scss', 'less', 'jsonc', 'json5', 'go', 'rs', 'swift', 'java', 'kt', 'c', 'h', 'cpp', 'zig'])
35
+ const HASH = new Set(['sh', 'bash', 'zsh', 'fish', 'yaml', 'yml', 'toml', 'py', 'rb', 'pl', 'r', 'ini', 'conf', 'cfg', 'env', 'gitignore', 'dockerignore', 'nix'])
36
+ const HASH_NAMES = new Set(['Dockerfile', 'Makefile', 'Procfile', 'CODEOWNERS', '.gitignore', '.gitattributes', '.npmignore', '.dockerignore'])
37
+
38
+ function carriersOf(path: string): { carriers: Carrier[]; markdown: boolean } {
39
+ const name = path.split('/').at(-1) ?? path
40
+ const ext = name.includes('.') ? name.split('.').at(-1)!.toLowerCase() : ''
41
+ if (MARKDOWN.has(ext)) return { carriers: ['html'], markdown: true }
42
+ if (HTML.has(ext)) return { carriers: ['html'], markdown: false }
43
+ if (C_LIKE.has(ext)) return { carriers: ['slash', 'block'], markdown: false }
44
+ if (HASH.has(ext) || HASH_NAMES.has(name)) return { carriers: ['hash'], markdown: false }
45
+ return { carriers: ['html', 'slash', 'block', 'hash'], markdown: false }
46
+ }
47
+
48
+ const KEYWORD = 'sk-disable-(next-line|line|file)(?![\\w-])'
49
+ const OPENERS: Record<Carrier, RegExp> = {
50
+ html: new RegExp(`<!--\\s*${KEYWORD}(.*?)(?:-->|$)`, 'g'),
51
+ slash: new RegExp(`//\\s*${KEYWORD}(.*)$`, 'g'),
52
+ block: new RegExp(`/\\*\\s*${KEYWORD}(.*?)(?:\\*/|$)`, 'g'),
53
+ hash: new RegExp(`(?:^|\\s)#\\s*${KEYWORD}(.*)$`, 'g'),
54
+ }
55
+
56
+ // a lone dash, never two: `RV027--why` is an id and a glued reason, reported.
57
+ // It ends on a letter or digit, so `RV027: why` is an id and no reason
58
+ const ID = '[A-Za-z0-9](?:(?:[\\w./:]|-(?!-))*[A-Za-z0-9])?'
59
+ const BODY = new RegExp(`^\\s+(${ID}(?:\\s*,\\s*${ID})*)(.*)$`)
60
+ const REASON = /^\s+--\s*(.*?)\s*$/
61
+
62
+ /** a markdown line with its inline code spans blanked, so a page that shows
63
+ * the grammar in backticks declares nothing */
64
+ const withoutInlineCode = (line: string): string => line.replace(/(`+)[\s\S]*?\1/g, (m) => ' '.repeat(m.length))
65
+
66
+ /** every directive in a file, in line order. A keyword followed by no id is
67
+ * not a directive at all (`<ID>` in prose about the grammar). One that names
68
+ * ids but no ` -- <reason>` is kept with `reason: null`, so the check it
69
+ * names can say it was ignored */
70
+ export function parseDirectives(path: string, text: string): Directive[] {
71
+ if (!text.includes(DIRECTIVE_MARK)) return []
72
+ const { carriers, markdown } = carriersOf(path)
73
+ const fenced = markdown ? fences(text.slice(0, text.lastIndexOf(DIRECTIVE_MARK))) : []
74
+ const out: Directive[] = []
75
+ // only the lines holding the mark are read: a journal of a megabyte that
76
+ // mentions the grammar twice costs two lines
77
+ let lineNo = 1
78
+ let counted = 0
79
+ let last = -1
80
+ for (let at = text.indexOf(DIRECTIVE_MARK); at !== -1; at = text.indexOf(DIRECTIVE_MARK, at + 1)) {
81
+ const start = text.lastIndexOf('\n', at) + 1
82
+ if (start === last) continue
83
+ last = start
84
+ for (let n = text.indexOf('\n', counted); n !== -1 && n < start; n = text.indexOf('\n', n + 1)) { lineNo++; counted = n + 1 }
85
+ if (fenced.some(([a, b]) => start >= a && start < b)) continue
86
+ const end = text.indexOf('\n', at)
87
+ const raw = text.slice(start, end === -1 ? text.length : end)
88
+ const line = markdown ? withoutInlineCode(raw) : raw
89
+ for (const c of carriers) {
90
+ for (const m of line.matchAll(OPENERS[c])) {
91
+ const body = BODY.exec(m[2]!)
92
+ if (body == null) continue
93
+ const reason = REASON.exec(body[2]!)?.[1] ?? ''
94
+ out.push({ scope: m[1] as DirectiveScope, ids: body[1]!.split(',').map((s) => s.trim()), reason: reason === '' ? null : reason, line: lineNo })
95
+ }
96
+ }
97
+ }
98
+ return out
99
+ }
100
+
101
+ /** the char spans of the fenced blocks of a markdown text, fence lines
102
+ * included, an unclosed one running to the end. Given the text up to its
103
+ * last mark only, so a long journal is not walked past it */
104
+ function fences(text: string): [number, number][] {
105
+ const spans: [number, number][] = []
106
+ let open: { at: number; fence: string } | null = null
107
+ for (const m of text.matchAll(/^[ \t]{0,3}(`{3,}|~{3,})/gm)) {
108
+ const f = m[1]!
109
+ if (open == null) open = { at: m.index!, fence: f }
110
+ else if (f[0] === open.fence[0] && f.length >= open.fence.length) { spans.push([open.at, m.index! + m[0].length]); open = null }
111
+ }
112
+ if (open != null) spans.push([open.at, text.length])
113
+ return spans
114
+ }
115
+
116
+ /** whether a directive reaches a site of its file: a site with no line is
117
+ * reached by a file directive only */
118
+ export function reaches(d: Directive, line: number | undefined): boolean {
119
+ if (d.scope === 'file') return true
120
+ if (line == null) return false
121
+ return d.scope === 'line' ? line === d.line : line === d.line + 1
122
+ }
123
+
124
+ /** how a directive reads in a report */
125
+ export const spell = (d: Directive, id: string): string => `sk-disable-${d.scope} ${id}`
@@ -29,14 +29,9 @@ import { join } from 'path'
29
29
  import { atomicWrite } from './locked-write.ts'
30
30
  import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
31
31
  import type { SlowSuite, TestsConfig } from '../suite/judge-suites.ts'
32
- import { normWord, proseWordsProblems, type ProseAllowWord, type ProseBanWord, type ProseWords } from '../prose/ste.ts'
33
32
 
34
33
  export const GATE_BEGIN = '// >>> shipkit gate — managed block, the hub writes it, edit outside the markers'
35
34
  export const GATE_END = '// <<< shipkit gate'
36
- /** the second managed block, INSIDE the `prose: {` object: the repo's own
37
- * word lists for rule SK014. Same writer, same paranoia, its own markers */
38
- export const WORDS_BEGIN = '// >>> shipkit prose words — managed block, the hub writes it, edit outside the markers'
39
- export const WORDS_END = '// <<< shipkit prose words'
40
35
 
41
36
  /** every key this block owns. A repo declaring one of them outside the
42
37
  * markers keeps it, and the block refuses to write rather than duplicate it */
@@ -55,13 +50,6 @@ export type GateBlock = {
55
50
  tests: TestsConfig
56
51
  /** true when a key this block owns is declared outside the markers */
57
52
  declaredOutside: boolean
58
- /** the prose word lists as the config loads them */
59
- words: ProseWords
60
- /** the words block: `no-prose` when the config has no `prose: {` object
61
- * to write into, and the lists then mean nothing to rule SK014 anyway */
62
- wordsState: BlockState | 'no-prose'
63
- /** true when `prose.words` is declared outside the words markers */
64
- wordsOutside: boolean
65
53
  }
66
54
 
67
55
  export type GateEdit = {
@@ -71,16 +59,6 @@ export type GateEdit = {
71
59
  slow?: SlowSuite[]
72
60
  /** suite files to drop back to the quick tier */
73
61
  slowRemove?: string[]
74
- /** the prose word lists: entries to add or replace (keyed by word), and
75
- * words to drop from either list */
76
- words?: WordsEdit
77
- }
78
-
79
- export type WordsEdit = {
80
- allow?: ProseAllowWord[]
81
- ban?: ProseBanWord[]
82
- /** words to drop, from whichever list holds them */
83
- remove?: string[]
84
62
  }
85
63
 
86
64
  const sha = (s: string): string => createHash('sha256').update(s).digest('hex').slice(0, 16)
@@ -98,11 +76,6 @@ function indentOf(lines: string[]): string {
98
76
 
99
77
  const isBegin = (l: string): boolean => l.trim().startsWith('// >>> shipkit gate')
100
78
  const isEnd = (l: string): boolean => l.trim().startsWith('// <<< shipkit gate')
101
- const isWordsBegin = (l: string): boolean => l.trim().startsWith('// >>> shipkit prose words')
102
- const isWordsEnd = (l: string): boolean => l.trim().startsWith('// <<< shipkit prose words')
103
- /** the one line the words block can be written under: `prose: {` alone */
104
- const isProseOpen = (l: string): boolean => /^\s*prose:\s*\{\s*$/.test(l)
105
-
106
79
  /** a single quoted TS string literal, escapes and all */
107
80
  const q = (s: string): string => `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
108
81
  /** an object key: bare when it is a plain identifier, quoted otherwise */
@@ -138,70 +111,6 @@ export function renderGateBlock(off: Record<string, string>, tests: TestsConfig,
138
111
  return out
139
112
  }
140
113
 
141
- /** the bytes between the words markers, markers included. Entries sorted by
142
- * word, so the block is a function of the lists and a reorder by hand reads
143
- * as a hand edit */
144
- export function renderWordsBlock(words: ProseWords, pad: string, unit = ' '): string[] {
145
- const out = [`${pad}${WORDS_BEGIN}`]
146
- const allow = [...(words.allow ?? [])].sort((a, b) => normWord(a.word).localeCompare(normWord(b.word)))
147
- const ban = [...(words.ban ?? [])].sort((a, b) => normWord(a.word).localeCompare(normWord(b.word)))
148
- if (allow.length > 0 || ban.length > 0) {
149
- out.push(`${pad}words: {`)
150
- const entry = (e: object, keys: string[]): string => {
151
- const r = e as Record<string, unknown>
152
- return `{ ${keys.filter((f) => typeof r[f] === 'string' && r[f] !== '').map((f) => `${f}: ${q(r[f] as string)}`).join(', ')} },`
153
- }
154
- const p1 = pad + unit
155
- const p2 = p1 + unit
156
- if (allow.length > 0) {
157
- out.push(`${p1}allow: [`)
158
- for (const a of allow) out.push(`${p2}${entry(a, ['word', 'reason'])}`)
159
- out.push(`${p1}],`)
160
- }
161
- if (ban.length > 0) {
162
- out.push(`${p1}ban: [`)
163
- for (const b of ban) out.push(`${p2}${entry(b, ['word', 'to', 'reason'])}`)
164
- out.push(`${p1}],`)
165
- }
166
- out.push(`${pad}},`)
167
- }
168
- out.push(`${pad}${WORDS_END}`)
169
- return out
170
- }
171
-
172
- /** the words the loader read, in the shape the renderer writes: empty
173
- * strings dropped, so a round trip compares content and not spelling */
174
- const cleanWords = (w: ProseWords | undefined): ProseWords => {
175
- const strip = <T extends object>(e: T): T => Object.fromEntries(Object.entries(e).filter(([, v]) => v != null && v !== '')) as T
176
- const out: ProseWords = {}
177
- if ((w?.allow ?? []).length > 0) out.allow = w!.allow!.map(strip)
178
- if ((w?.ban ?? []).length > 0) out.ban = w!.ban!.map(strip)
179
- return out
180
- }
181
-
182
- /** the indentation one level inside the line at `at` */
183
- const innerPad = (lines: string[], at: number): string => {
184
- const own = /^([ \t]*)/.exec(lines[at] ?? '')?.[1] ?? ''
185
- const unit = indentOf(lines)
186
- return own + unit
187
- }
188
-
189
- /** where the words block sits and what state it is in */
190
- function locateWords(lines: string[], c: RepoConfig): { state: BlockState | 'no-prose'; b: number; e: number; open: number; pad: string; outside: boolean } {
191
- const b = lines.findIndex(isWordsBegin)
192
- const e = lines.findIndex(isWordsEnd)
193
- const open = lines.findIndex(isProseOpen)
194
- const words = cleanWords(c.prose?.words)
195
- if (b !== -1 && e !== -1 && e > b) {
196
- const pad = /^([ \t]*)/.exec(lines[b]!)?.[1] ?? ''
197
- const current = lines.slice(b, e + 1).join('\n')
198
- const expected = renderWordsBlock(words, pad, indentOf(lines)).join('\n')
199
- return { state: current === expected ? 'current' : 'stale', b, e, open, pad, outside: false }
200
- }
201
- if (open === -1) return { state: 'no-prose', b: -1, e: -1, open, pad: '', outside: c.prose?.words != null }
202
- return { state: 'missing-block', b: -1, e: -1, open, pad: innerPad(lines, open), outside: c.prose?.words != null }
203
- }
204
-
205
114
  /** is repo.config.ts edited and not yet committed? The switch leaves it
206
115
  * DIRTY on purpose: a toggle is a change its owner reviews in a git client,
207
116
  * never a commit an agent makes for them. So the page has to say so. */
@@ -219,7 +128,7 @@ export function configDirty(root: string): boolean {
219
128
  /** what the block holds today, and whether it still describes the config */
220
129
  export async function readGateBlock(root: string): Promise<GateBlock> {
221
130
  const path = configPath(root)
222
- if (!existsSync(path)) return { path, hash: '', state: 'missing-file', off: {}, tests: {}, declaredOutside: false, words: {}, wordsState: 'no-prose', wordsOutside: false }
131
+ if (!existsSync(path)) return { path, hash: '', state: 'missing-file', off: {}, tests: {}, declaredOutside: false }
223
132
  const text = readFileSync(path, 'utf8')
224
133
  const lines = text.split('\n')
225
134
  const b = lines.findIndex(isBegin)
@@ -228,13 +137,11 @@ export async function readGateBlock(root: string): Promise<GateBlock> {
228
137
  const off = c.off ?? {}
229
138
  const tests = c.tests ?? {}
230
139
  const hash = sha(text)
231
- const w = locateWords(lines, c)
232
- const wordsPart = { words: cleanWords(c.prose?.words), wordsState: w.state, wordsOutside: w.outside }
233
140
  if (b === -1 || e === -1 || e < b) {
234
141
  // no block: the keys it owns must be unclaimed, or a write would be a
235
142
  // second declaration of the same key rather than an edit of the first
236
143
  const declaredOutside = OWNED.some((key) => c[key] != null)
237
- return { path, hash, state: 'missing-block', off, tests, declaredOutside, ...wordsPart }
144
+ return { path, hash, state: 'missing-block', off, tests, declaredOutside }
238
145
  }
239
146
  // the block DESCRIBES the config when re-rendering the loaded values
240
147
  // reproduces it byte for byte. Anything else (a hand edit, a second
@@ -243,7 +150,7 @@ export async function readGateBlock(root: string): Promise<GateBlock> {
243
150
  const pad = indentOf(lines.slice(b + 1, e))
244
151
  const current = lines.slice(b, e + 1).join('\n')
245
152
  const expected = renderGateBlock(off, tests, pad === '' ? indentOf(lines) : pad).join('\n')
246
- return { path, hash, state: current === expected ? 'current' : 'stale', off, tests, declaredOutside: false, ...wordsPart }
153
+ return { path, hash, state: current === expected ? 'current' : 'stale', off, tests, declaredOutside: false }
247
154
  }
248
155
 
249
156
  /** does what the loader read back say what was written? BOTH keys the block
@@ -252,7 +159,7 @@ export async function readGateBlock(root: string): Promise<GateBlock> {
252
159
  * what reading the result back exists to catch, and comparing half of the
253
160
  * block would let that half through. Key and array ORDER are the renderer's,
254
161
  * so the comparison is by content. */
255
- export function roundTripProblem(wrote: { off: Record<string, string>; tests: TestsConfig; words?: ProseWords }, loaded: RepoConfig): string | null {
162
+ export function roundTripProblem(wrote: { off: Record<string, string>; tests: TestsConfig }, loaded: RepoConfig): string | null {
256
163
  const normOff = (m: Record<string, string> | undefined): string => JSON.stringify(Object.entries(m ?? {}).sort())
257
164
  const normTests = (t: TestsConfig | undefined): string =>
258
165
  JSON.stringify({
@@ -262,72 +169,20 @@ export function roundTripProblem(wrote: { off: Record<string, string>; tests: Te
262
169
  })
263
170
  if (normOff(loaded.off) !== normOff(wrote.off)) return `the loader read off ${JSON.stringify(loaded.off ?? {})}`
264
171
  if (normTests(loaded.tests) !== normTests(wrote.tests)) return `the loader read tests ${JSON.stringify(loaded.tests ?? {})}`
265
- if (wrote.words != null) {
266
- const normWords = (x: ProseWords | undefined): string => {
267
- const c = cleanWords(x)
268
- const by = (a: { word: string }, b: { word: string }) => normWord(a.word).localeCompare(normWord(b.word))
269
- return JSON.stringify({ allow: [...(c.allow ?? [])].sort(by), ban: [...(c.ban ?? [])].sort(by) })
270
- }
271
- if (normWords(loaded.prose?.words) !== normWords(wrote.words)) return `the loader read prose.words ${JSON.stringify(loaded.prose?.words ?? {})}`
272
- }
273
172
  return null
274
173
  }
275
174
 
276
- /** the next word lists: an entry replaces the one with the same word in
277
- * EITHER list (moving a word from allow to ban is one edit), then `remove`
278
- * drops words. Judged the way defineRepo judges them */
279
- export function nextWords(before: ProseWords, edit: WordsEdit): ProseWords {
280
- const allow = [...(before.allow ?? [])]
281
- const ban = [...(before.ban ?? [])]
282
- const drop = (w: string): void => {
283
- const n = normWord(w)
284
- for (const list of [allow, ban] as { word: string }[][]) {
285
- const at = list.findIndex((x) => normWord(x.word) === n)
286
- if (at !== -1) list.splice(at, 1)
287
- }
288
- }
289
- const trimmed = <T extends { word: string }>(e: T): T =>
290
- Object.fromEntries(Object.entries(e).filter(([, v]) => typeof v === 'string' && v.trim() !== '').map(([key, v]) => [key, (v as string).trim()])) as T
291
- for (const a of edit.allow ?? []) {
292
- if (typeof a?.word !== 'string' || normWord(a.word) === '') throw new Error(`prose.words.allow needs a word`)
293
- drop(a.word)
294
- allow.push(trimmed({ word: a.word, reason: a.reason }))
295
- }
296
- for (const b of edit.ban ?? []) {
297
- if (typeof b?.word !== 'string' || normWord(b.word) === '') throw new Error(`prose.words.ban needs a word`)
298
- drop(b.word)
299
- ban.push(trimmed({ word: b.word, to: b.to, reason: b.reason }))
300
- }
301
- for (const w of edit.remove ?? []) drop(w)
302
- const out: ProseWords = {}
303
- if (allow.length > 0) out.allow = allow
304
- if (ban.length > 0) out.ban = ban
305
- const problems = proseWordsProblems(out)
306
- if (problems.length > 0) throw new Error(problems[0])
307
- return out
308
- }
309
-
310
- /** the ONE write path, for the switch, the slow suite form and the prose word
311
- * lists alike. Each block is rewritten only when the edit touches it, so a
312
- * word edit never inserts an empty gate block, and the reverse */
175
+ /** the ONE write path, for the switch and the slow suite form alike */
313
176
  export async function writeGateBlock(root: string, edit: GateEdit, o: { expect?: string } = {}): Promise<GateBlock> {
314
177
  const before = await readGateBlock(root)
315
178
  if (before.state === 'missing-file') throw new Error(`no repo.config.ts in ${root} — run: shipkit init`)
316
179
  if (o.expect != null && o.expect !== before.hash)
317
180
  throw new Error(`repo.config.ts changed since you read it, reload the page and set it again, so this edit cannot overwrite one made elsewhere`)
318
- const touchGate = edit.off != null || edit.slow != null || edit.slowRemove != null
319
- const touchWords = edit.words != null
320
- if (!touchGate && !touchWords) throw new Error(`nothing to write: the edit names no off, no slow suite and no prose word`)
321
- if (touchGate && before.state === 'stale')
181
+ if (edit.off == null && edit.slow == null && edit.slowRemove == null) throw new Error(`nothing to write: the edit names no off and no slow suite`)
182
+ if (before.state === 'stale')
322
183
  throw new Error(`the shipkit gate block in repo.config.ts no longer matches what the config loads, so it was hand edited or something else declares these keys — reconcile it by hand, then use the switch again`)
323
- if (touchGate && before.state === 'missing-block' && before.declaredOutside)
184
+ if (before.state === 'missing-block' && before.declaredOutside)
324
185
  throw new Error(`repo.config.ts declares \`off\` or \`tests\` outside the managed block — move that declaration between the markers first, or the switch would write a duplicate key`)
325
- if (touchWords && before.wordsState === 'no-prose')
326
- throw new Error(`repo.config.ts has no \`prose: {\` line to write the word lists under — declare \`prose: { include: [...] }\` first, the lists only matter to a repo rule SK014 checks`)
327
- if (touchWords && before.wordsState === 'stale')
328
- throw new Error(`the shipkit prose words block in repo.config.ts no longer matches what the config loads, so it was hand edited — reconcile it by hand, then edit the lists again`)
329
- if (touchWords && before.wordsState === 'missing-block' && before.wordsOutside)
330
- throw new Error(`repo.config.ts declares \`prose.words\` outside the managed block — move it between the markers first, or the page would write a duplicate key`)
331
186
 
332
187
  // the next values, validated the way defineRepo validates them, BEFORE
333
188
  // anything reaches the file
@@ -350,47 +205,32 @@ export async function writeGateBlock(root: string, edit: GateEdit, o: { expect?:
350
205
  }
351
206
  const dropped = new Set(edit.slowRemove ?? [])
352
207
  const tests: TestsConfig = { ...before.tests, slow: slow.filter((s) => !dropped.has(s.file)) }
353
- const words = touchWords ? nextWords(before.words, edit.words!) : undefined
354
208
 
355
209
  const text = readFileSync(before.path, 'utf8')
356
- let out = text.split('\n')
357
- if (touchGate) {
358
- const lines = out
359
- const b = lines.findIndex(isBegin)
360
- const e = lines.findIndex(isEnd)
361
- const pad = b === -1 ? indentOf(lines) : indentOf(lines.slice(b + 1, e)) || indentOf(lines)
362
- const block = renderGateBlock(off, tests, pad)
363
- if (b !== -1 && e !== -1) out = [...lines.slice(0, b), ...block, ...lines.slice(e + 1)]
364
- else {
365
- // the anchor is the object literal `defineRepo({` opens. Without it
366
- // there is nothing to write into, so the block is PRINTED for a
367
- // human to paste rather than guessed at
368
- const anchor = lines.findIndex((l) => /defineRepo\(\s*\{\s*$/.test(l))
369
- if (anchor === -1)
370
- throw new Error(`repo.config.ts has no \`defineRepo({\` line to write into — paste this block inside the config object by hand:\n${block.join('\n')}`)
371
- out = [...lines.slice(0, anchor + 1), ...block, ...lines.slice(anchor + 1)]
372
- }
373
- }
374
- if (words != null) {
375
- const lines = out
376
- const b = lines.findIndex(isWordsBegin)
377
- const e = lines.findIndex(isWordsEnd)
378
- const unit = indentOf(lines)
379
- if (b !== -1 && e > b) {
380
- const pad = /^([ \t]*)/.exec(lines[b]!)?.[1] ?? ''
381
- out = [...lines.slice(0, b), ...renderWordsBlock(words, pad, unit), ...lines.slice(e + 1)]
382
- } else {
383
- const open = lines.findIndex(isProseOpen)
384
- out = [...lines.slice(0, open + 1), ...renderWordsBlock(words, innerPad(lines, open), unit), ...lines.slice(open + 1)]
385
- }
210
+ let out: string[]
211
+ const lines = text.split('\n')
212
+ const b = lines.findIndex(isBegin)
213
+ const e = lines.findIndex(isEnd)
214
+ const pad = b === -1 ? indentOf(lines) : indentOf(lines.slice(b + 1, e)) || indentOf(lines)
215
+ const block = renderGateBlock(off, tests, pad)
216
+ if (b !== -1 && e !== -1) out = [...lines.slice(0, b), ...block, ...lines.slice(e + 1)]
217
+ else {
218
+ // the anchor is the object literal `defineRepo({` opens. Without it
219
+ // there is nothing to write into, so the block is PRINTED for a
220
+ // human to paste rather than guessed at
221
+ const anchor = lines.findIndex((l) => /defineRepo\(\s*\{\s*$/.test(l))
222
+ if (anchor === -1)
223
+ throw new Error(`repo.config.ts has no \`defineRepo({\` line to write into — paste this block inside the config object by hand:\n${block.join('\n')}`)
224
+ out = [...lines.slice(0, anchor + 1), ...block, ...lines.slice(anchor + 1)]
386
225
  }
226
+
387
227
  atomicWrite(before.path, out.join('\n'))
388
228
 
389
229
  // read it back through the loader the cli uses. A block that does not
390
230
  // round trip is a broken config, so the previous bytes go back
391
231
  try {
392
232
  const after = await loadRepoConfig(root, { fresh: true })
393
- const problem = roundTripProblem({ off, tests, words }, after)
233
+ const problem = roundTripProblem({ off, tests }, after)
394
234
  if (problem != null) throw new Error(`the block did not round trip: ${problem}`)
395
235
  } catch (err) {
396
236
  atomicWrite(before.path, text)
package/src/core/when.ts CHANGED
@@ -1,26 +1,28 @@
1
1
  // THE CHECKS A REPO TURNS OFF: `off: { <id>: '<why>' }` in repo.config.ts,
2
2
  // the switch the hub writes inside its managed block. Where a check runs is
3
- // its own declaration (core/runs.ts); the one thing a repo says about a check
4
- // it does not own is that it does not run here, and why. An off row runs in
3
+ // its own declaration (core/runs.ts); a repo says of a whole check it does
4
+ // not own only that it does not run here, and why. An off row runs in
5
5
  // no context, and every surface shows it with its reason.
6
6
  //
7
- // Two retired spellings still read as off while SK029 ports them: a `when`
8
- // entry `'off:<why>'` (defineRepo refuses the other words) and `ruleExempt`.
9
- // They die once `shipkit fleet` shows no repo carrying them.
7
+ // Four retired spellings still read as off while SK029 ports them: a `when`
8
+ // entry `'off:<why>'` (defineRepo refuses the other words), `ruleExempt`,
9
+ // `hooks.disable` and a floor slot `defaults: { <slot>: false }`. The last two
10
+ // never carried a reason, so they read with one that says so. They die once
11
+ // `shipkit fleet` shows no repo carrying them.
10
12
  import type { CheckRow } from '../ledger/gate-checks.ts'
11
13
  import type { ResolvedRuns } from './runs.ts'
12
14
  import type { RepoConfig } from '../repo-config.ts'
13
15
 
14
16
  /** the coarse word a resolved row reads as on the check page */
15
- export type WhenAt = 'always' | 'release' | 'health' | 'off'
17
+ export type WhenAt = 'always' | 'release' | 'health' | 'commit' | 'off'
16
18
 
17
19
  /** which declaration is answering: the repo's `off`, one of its two retired
18
20
  * spellings, the check file, the `defaults` block, or a derived row */
19
- export type WhenSource = 'off' | 'when' | 'ruleExempt' | 'check-file' | 'defaults' | 'default'
21
+ export type WhenSource = 'off' | 'when' | 'ruleExempt' | 'hooks.disable' | 'check-file' | 'defaults' | 'default'
20
22
 
21
23
  export type WhenDecl = { at: WhenAt; reason: string | null; source: WhenSource }
22
24
 
23
- export const WHEN_AT: readonly WhenAt[] = ['always', 'release', 'health', 'off']
25
+ export const WHEN_AT: readonly WhenAt[] = ['always', 'release', 'health', 'commit', 'off']
24
26
 
25
27
  /** the words a retired `when` value used */
26
28
  const WORDS: Record<string, WhenAt> = { always: 'always', release: 'release', health: 'health' }
@@ -41,15 +43,30 @@ export function parseWhen(raw: unknown): WhenDecl | string {
41
43
  }
42
44
 
43
45
  /** the repo turned this check off, with why and where it said so, or null */
44
- export type RepoOff = { reason: string; source: 'off' | 'when' | 'ruleExempt' }
46
+ export type RepoOff = { reason: string; source: 'off' | 'when' | 'ruleExempt' | 'hooks.disable' | 'defaults' }
45
47
 
46
- export function repoOff(c: RepoConfig, id: string): RepoOff | null {
48
+ /** the reason a spelling that never carried one reads with, and the one the
49
+ * SK029 port writes when no comment beside the entry says more. A machine
50
+ * cannot invent a reason, so every surface shows the gap */
51
+ export const legacyOffReason = (source: 'hooks.disable' | 'defaults', id: string): string =>
52
+ source === 'defaults' ? `was defaults: { ${/^[A-Za-z_$][\w$]*$/.test(id) ? id : `'${id}'`}: false }, no reason recorded` : `was hooks.disable: ['${id}'], no reason recorded`
53
+
54
+ /** the keys `repoOff` reads, so a caller holding part of a config can ask */
55
+ export type OffKeys = Pick<RepoConfig, 'off' | 'when' | 'ruleExempt'> & { hooks?: { disable?: readonly string[] }; defaults?: Record<string, unknown> }
56
+
57
+ /** THE one answer to "is this id off here", for a check, a floor slot and a
58
+ * commit gate alike. A floor slot that is off is off everywhere: no task, no
59
+ * routed script, no deny rule */
60
+ export function repoOff(c: OffKeys, id: string): RepoOff | null {
47
61
  const own = c.off?.[id]
48
62
  if (own != null) return { reason: own, source: 'off' }
49
63
  const w = c.when?.[id] == null ? null : parseWhen(c.when[id])
50
64
  if (w != null && typeof w !== 'string' && w.at === 'off') return { reason: w.reason ?? '', source: 'when' }
51
65
  const exempt = c.ruleExempt?.[id]
52
- return exempt == null ? null : { reason: exempt, source: 'ruleExempt' }
66
+ if (exempt != null) return { reason: exempt, source: 'ruleExempt' }
67
+ if (c.hooks?.disable?.includes(id) === true) return { reason: legacyOffReason('hooks.disable', id), source: 'hooks.disable' }
68
+ if (c.defaults?.[id] === false) return { reason: legacyOffReason('defaults', id), source: 'defaults' }
69
+ return null
53
70
  }
54
71
 
55
72
  /** a resolved row the repo turned off: it runs in no context */
@@ -57,10 +74,10 @@ export const withRepoOff = (base: ResolvedRuns, off: RepoOff | null): ResolvedRu
57
74
 
58
75
  /** the coarse word a resolved row reads as, for the check page. Derived,
59
76
  * never declared */
60
- export function whenSummary(r: ResolvedRuns, off: RepoOff | { reason: string; source: 'defaults' } | null, fromFile: boolean): WhenDecl {
77
+ export function whenSummary(r: ResolvedRuns, off: RepoOff | null, fromFile: boolean, o: { hook?: boolean } = {}): WhenDecl {
61
78
  const source: WhenSource = off?.source ?? (fromFile ? 'check-file' : 'default')
62
79
  if (r.off != null) return { at: 'off', reason: r.off, source }
63
- const at: WhenAt = r.onCi ? 'always' : r.onFull || r.onRelease ? 'release' : r.onHealth || r.onSave !== false || r.onClock !== false ? 'health' : 'off'
80
+ const at: WhenAt = r.onCi ? 'always' : r.onFull || r.onRelease ? 'release' : r.onHealth || r.onSave !== false || r.onClock !== false ? 'health' : o.hook === true || r.onPreCommit !== false ? 'commit' : 'off'
64
81
  return { at, reason: at === 'off' ? 'no context runs it' : null, source }
65
82
  }
66
83
 
package/src/core/wire.ts CHANGED
@@ -13,7 +13,23 @@ export { spanShort }
13
13
  * as a future expiry long after it passed */
14
14
  export type Stamp = { at: number; until: number | null; cached?: boolean; ranMs?: number }
15
15
 
16
- export type Line = { kind: 'ok' | 'warn' | 'fail' | 'note' | 'fix-warn' | 'fix-fail' | 'skip'; text: string; stamp?: Stamp }
16
+ /** where a finding sits: a repo-relative file, and its line and column when
17
+ * the check knows them. A site is what an in-file directive can waive
18
+ * (core/waivers.ts) and what the problems pane opens */
19
+ export type Site = { file: string; line?: number; col?: number }
20
+
21
+ export type Line = {
22
+ kind: 'ok' | 'warn' | 'fail' | 'note' | 'fix-warn' | 'fix-fail' | 'skip'
23
+ text: string
24
+ stamp?: Stamp
25
+ at?: Site
26
+ /** a waiver report: `count` prints with the check, `site` lists one
27
+ * waived site and prints only where every line does (--verbose, STATUS.md) */
28
+ waiver?: 'count' | 'site'
29
+ }
30
+
31
+ /** `file:line:col`, as far as the site knows */
32
+ export const siteText = (s: Site): string => `${s.file}${s.line == null ? '' : `:${s.line}${s.col == null ? '' : `:${s.col}`}`}`
17
33
 
18
34
  /** `cached, 3h old, kept 57m more` for a replay, `ran 0.4s, kept 1h more`
19
35
  * for an answer this run measured, `ran 1d ago, expired 1d ago` for one
@@ -323,7 +323,7 @@ export function ensureIgnoreBaseline(root: string): void {
323
323
 
324
324
  /** never in a first commit, wherever it sits in the tree. `.env.example` and
325
325
  * friends are documentation and belong in the repo */
326
- function isRisky(f: string): boolean {
326
+ export function isRisky(f: string): boolean {
327
327
  if (f.split('/').includes('node_modules')) return true
328
328
  const base = basename(f)
329
329
  return base.startsWith('.env') && !/^\.env\.(example|sample|template|dist)$/.test(base)