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.
Files changed (109) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/package.json +1 -1
  3. package/src/check.ts +5 -2
  4. package/src/checks/SK001-package-json-shape.check.ts +47 -12
  5. package/src/checks/SK013-one-way-in-policy.check.ts +8 -7
  6. package/src/checks/SK017-vscode-hub-task.check.ts +109 -106
  7. package/src/checks/SK019-tests-run-in-units.check.ts +2 -1
  8. package/src/checks/SK027-agent-wiki-links.check.ts +5 -2
  9. package/src/checks/SK029-explicit-check-files.check.ts +25 -8
  10. package/src/checks/SK034-commit-gates-armed.check.ts +90 -0
  11. package/src/cli/cmd_Ci.ts +3 -3
  12. package/src/cli/cmd_Commands.ts +21 -5
  13. package/src/cli/cmd_Hooks.ts +6 -2
  14. package/src/cli/cmd_Init.ts +1 -1
  15. package/src/cli/cmd_Serve.ts +1 -2
  16. package/src/cli/cmd_Start.ts +1 -1
  17. package/src/cli.ts +2 -4
  18. package/src/core/budget.ts +6 -8
  19. package/src/core/bun-builtins.ts +36 -0
  20. package/src/core/bun-tool-cli.ts +23 -0
  21. package/src/core/canonical-scripts.ts +6 -4
  22. package/src/core/check-ctx.ts +104 -10
  23. package/src/core/check-file-names.ts +2 -3
  24. package/src/core/check-pass.ts +17 -1
  25. package/src/core/check-runner.ts +19 -6
  26. package/src/core/config-rewrite.ts +27 -6
  27. package/src/core/discover-checks.ts +30 -10
  28. package/src/core/floor-resolve.ts +16 -7
  29. package/src/core/job-run.ts +2 -2
  30. package/src/core/layout.ts +4 -1
  31. package/src/core/legacy-shapes.ts +40 -0
  32. package/src/core/one-lib.ts +1 -1
  33. package/src/core/one-way-in-policy.ts +106 -13
  34. package/src/core/pre-commit-discovery.ts +106 -0
  35. package/src/core/retired-checks.ts +2 -0
  36. package/src/core/run-ci.ts +16 -10
  37. package/src/core/run-fix.ts +9 -0
  38. package/src/core/run-fixers.ts +91 -35
  39. package/src/core/runs-matrix.ts +7 -0
  40. package/src/core/slots.ts +1 -1
  41. package/src/core/vendored-read.ts +39 -0
  42. package/src/core/vendored.ts +82 -0
  43. package/src/core/waivers.ts +125 -0
  44. package/src/core/when-block.ts +25 -185
  45. package/src/core/when.ts +30 -13
  46. package/src/core/wire.ts +17 -1
  47. package/src/fleet/update-fleet.ts +11 -2
  48. package/src/git-bootstrap.ts +1 -1
  49. package/src/home/attach.ts +3 -5
  50. package/src/home/insights.ts +6 -1
  51. package/src/home/pm-config.ts +2 -2
  52. package/src/home/run-daemon.ts +2 -2
  53. package/src/home/run-unit-action.ts +2 -2
  54. package/src/hooks/commit-message.ts +2 -14
  55. package/src/hooks/hook-gates.ts +38 -0
  56. package/src/hooks/leak-guard.ts +8 -3
  57. package/src/hooks/one-way-in-guard.ts +8 -2
  58. package/src/hooks/root-identity.ts +9 -6
  59. package/src/hooks/run-hooks.ts +72 -41
  60. package/src/hooks/staged.ts +49 -0
  61. package/src/hooks/tree-shrink.ts +41 -0
  62. package/src/init-repo.ts +0 -42
  63. package/src/ledger/check-table.ts +1 -1
  64. package/src/ledger/gate-checks.ts +45 -9
  65. package/src/ledger/ledger.ts +6 -1
  66. package/src/ledger/repo-view.ts +14 -4
  67. package/src/ledger/toolchain-identity.ts +4 -2
  68. package/src/mv/mv-md.ts +6 -1
  69. package/src/problems/extract.ts +27 -5
  70. package/src/problems/problem-line.ts +2 -3
  71. package/src/release/run-release.ts +13 -8
  72. package/src/repo-config.ts +44 -27
  73. package/src/scaffold/files/bunfig.toml +11 -0
  74. package/src/scaffold/files/vscode-settings.json +1 -2
  75. package/src/scaffold/jsonc-spans.ts +16 -0
  76. package/src/scaffold/project-scaffold.ts +43 -77
  77. package/src/serve/hub-app/check-view.tsx +4 -4
  78. package/src/serve/hub-app/check-words.ts +1 -0
  79. package/src/serve/hub-app/checks-svc.ts +1 -2
  80. package/src/serve/hub-app/home.html +0 -1
  81. package/src/serve/hub-app/hub-types.ts +0 -2
  82. package/src/serve/hub-app/index.html +0 -1
  83. package/src/serve/hub-app/project-list.css +1 -2
  84. package/src/serve/hub-app/tree-build.ts +3 -2
  85. package/src/serve/hub-app/tree-svc.ts +2 -2
  86. package/src/serve/hub-port.ts +3 -3
  87. package/src/serve/hub-runtime.ts +0 -3
  88. package/src/serve/serve-hub.ts +1 -16
  89. package/src/serve/tracked-watch.ts +1 -1
  90. package/src/start-repo.ts +15 -3
  91. package/src/suite/judge-suites.ts +29 -28
  92. package/src/suite/select.ts +2 -0
  93. package/src/suite/suite-cli.ts +6 -3
  94. package/src/suite/suite-cpu-preload.ts +7 -11
  95. package/src/suite/test-units.ts +7 -4
  96. package/src/ts/config.ts +12 -5
  97. package/src/ts/imports.ts +3 -1
  98. package/src/tui/build-steps-panel.ts +1 -1
  99. package/src/wiki/scan-md.ts +16 -9
  100. package/src/checks/SK009-commit-ai-tells.check.ts +0 -131
  101. package/src/checks/SK014-prose-ste.check.ts +0 -78
  102. package/src/cli/cmd_Prose.ts +0 -74
  103. package/src/prose/prose-view.ts +0 -90
  104. package/src/prose/run-prose.ts +0 -187
  105. package/src/prose/ste.ts +0 -552
  106. package/src/scaffold/files/vscode-tasks.json +0 -24
  107. package/src/serve/hub-app/prose-hits-st.ts +0 -125
  108. package/src/serve/hub-app/prose-hits-view.tsx +0 -176
  109. package/src/serve/hub-app/prose-hits.css +0 -21
package/src/prose/ste.ts DELETED
@@ -1,552 +0,0 @@
1
- // STE prose checker — the mechanical subset of ASD-STE100 (Issue 9) that a
2
- // machine can judge without reading for meaning. The standard has 53 rules
3
- // and a ~900-word dictionary; this file implements the classes where a regex
4
- // is not a guess, and stays silent everywhere else. The house standard and
5
- // the exemptions: identity/writing-voice.md.
6
- //
7
- // Three tiers, because the cost of being wrong is not the same for each:
8
- // fix — one mechanical rewrite, meaning preserved. `--fix` applies it.
9
- // hard — a real violation whose repair is a judgement call. Reported.
10
- // soft — needs a human read (length, voice). Warns, never blocks alone.
11
- //
12
- // The class ids are STABLE: a baseline count and a `steAllow` entry both
13
- // name them.
14
-
15
- export type ProseTier = 'fix' | 'hard' | 'soft'
16
-
17
- export type ProseClass =
18
- | 'contraction'
19
- | 'abbrev'
20
- | 'slop'
21
- | 'modal'
22
- | 'semicolon'
23
- | 'comma-ing'
24
- | 'perfect'
25
- | 'vague'
26
- | 'sentence'
27
- | 'passive'
28
- | 'paragraph'
29
- | 'ban'
30
-
31
- export type ProseHit = {
32
- line: number // 1-based, true to the file
33
- start: number // offset into the source
34
- end: number
35
- cls: ProseClass
36
- tier: ProseTier
37
- text: string // the offending span
38
- fix?: string // the replacement, when the class is mechanical
39
- why: string // one line, what STE wants instead
40
- /** the dictionary entry that fired, lowercased: what a repo `allow` entry
41
- * names and what the hub groups by. Absent on the classes that judge a
42
- * shape (sentence length, paragraph, semicolon) rather than a word */
43
- word?: string
44
- }
45
-
46
- /** a word or phrase this repo accepts: every hit on it is dropped */
47
- export type ProseAllowWord = {
48
- /** the word or phrase, case ignored. A dictionary entry (`should`,
49
- * `leverage`) covers its inflected forms too */
50
- word: string
51
- /** one line: why this repo accepts it */
52
- reason?: string
53
- }
54
-
55
- /** a word or phrase this repo refuses on top of the house dictionary */
56
- export type ProseBanWord = {
57
- /** the word or phrase, case ignored, matched on word boundaries */
58
- word: string
59
- /** what to write instead. Given, `shipkit prose --fix` writes it */
60
- to?: string
61
- /** one line: why this repo refuses it */
62
- reason?: string
63
- }
64
-
65
- /** the per repo word lists. The house dictionary in this file stays the
66
- * default, and these override it for one repo only */
67
- export type ProseWords = {
68
- /** words and phrases this repo accepts */
69
- allow?: ProseAllowWord[]
70
- /** words and phrases this repo refuses, each with its replacement */
71
- ban?: ProseBanWord[]
72
- }
73
-
74
- export type ProseOpts = {
75
- /** class ids this file or repo does not want checked */
76
- allow?: ProseClass[]
77
- /** procedural limit (20) instead of the descriptive one (25) */
78
- strict?: boolean
79
- /** the repo's own allow and ban lists */
80
- words?: ProseWords
81
- }
82
-
83
- const TIER: Record<ProseClass, ProseTier> = {
84
- contraction: 'fix',
85
- abbrev: 'fix',
86
- slop: 'fix',
87
- modal: 'soft',
88
- // reports, never blocks: the join carries meaning a rewrite can lose
89
- semicolon: 'soft',
90
- 'comma-ing': 'soft',
91
- perfect: 'soft',
92
- vague: 'hard',
93
- sentence: 'soft',
94
- passive: 'soft',
95
- paragraph: 'soft',
96
- // the repo named the word itself, so a hit is never a guess
97
- ban: 'hard',
98
- }
99
-
100
- export const PROSE_CLASSES = Object.keys(TIER) as ProseClass[]
101
- export const tierOf = (c: ProseClass): ProseTier => TIER[c]
102
-
103
- // --- vocabulary tables ---------------------------------------------------
104
-
105
- /** contractions → the full form. STE keeps every word a reader can look up. */
106
- const CONTRACTIONS: Record<string, string> = {
107
- "aren't": 'are not', "can't": 'cannot', "couldn't": 'could not', "didn't": 'did not',
108
- "doesn't": 'does not', "don't": 'do not', "hadn't": 'had not', "hasn't": 'has not',
109
- "haven't": 'have not', "isn't": 'is not', "mustn't": 'must not', "shouldn't": 'should not',
110
- "wasn't": 'was not', "weren't": 'were not', "won't": 'will not', "wouldn't": 'would not',
111
- "he's": 'he is', "here's": 'here is', "it's": 'it is', "let's": 'let us', "she's": 'she is',
112
- "that's": 'that is', "there's": 'there is', "they're": 'they are', "they've": 'they have',
113
- "we're": 'we are', "we've": 'we have', "what's": 'what is', "who's": 'who is',
114
- "you're": 'you are', "you've": 'you have', "i'm": 'I am', "we'll": 'we will',
115
- "you'll": 'you will', "it'll": 'it will', "they'll": 'they will',
116
- }
117
-
118
- /** latin and shorthand a non-native reader must decode */
119
- const ABBREV: Record<string, string> = {
120
- 'e.g.': 'for example', 'i.e.': 'that is', 'vs.': 'against', 'w/': 'with',
121
- }
122
-
123
- /** one mechanical replacement, meaning preserved. Sourced from the STE
124
- * dictionary's not-approved column where the alternative is unambiguous,
125
- * plus the AI-slop words rule 1.5 (keep it short) covers.
126
- * `verb: true` lets the checker carry the inflection across, so `utilizes`
127
- * becomes `uses` and not `utilizes`. Without it a table of base forms
128
- * silently misses most real prose. */
129
- const SUBSTITUTIONS: { from: string; to: string; verb?: boolean }[] = [
130
- { from: 'leverage', to: 'use', verb: true },
131
- { from: 'utilize', to: 'use', verb: true },
132
- { from: 'commence', to: 'start', verb: true },
133
- { from: 'terminate', to: 'stop', verb: true },
134
- { from: 'obtain', to: 'get', verb: true },
135
- { from: 'facilitate', to: 'help', verb: true },
136
- { from: 'streamline', to: 'make simpler', verb: false },
137
- { from: 'prior to', to: 'before' },
138
- { from: 'in order to', to: 'to' },
139
- { from: 'due to the fact that', to: 'because' },
140
- { from: 'in the event that', to: 'if' },
141
- { from: 'out of the box', to: 'by default' },
142
- { from: 'under the hood', to: 'internally' },
143
- { from: 'a number of', to: 'some' },
144
- { from: 'is able to', to: 'can' },
145
- { from: 'are able to', to: 'can' },
146
- { from: 'has the ability to', to: 'can' },
147
- { from: 'plethora', to: 'many' },
148
- { from: 'myriad', to: 'many' },
149
- { from: 'additionally', to: 'also' },
150
- { from: 'furthermore', to: 'also' },
151
- { from: 'moreover', to: 'also' },
152
- ]
153
-
154
- /** carry an English verb inflection from the banned word onto its
155
- * replacement. Only the four regular endings, because an irregular form is
156
- * a guess and this table exists to avoid guesses. */
157
- const IRREGULAR: Record<string, Record<string, string>> = {
158
- get: { s: 'gets', es: 'gets', d: 'got', ed: 'got', ing: 'getting' },
159
- stop: { s: 'stops', es: 'stops', d: 'stopped', ed: 'stopped', ing: 'stopping' },
160
- run: { s: 'runs', es: 'runs', d: 'ran', ed: 'ran', ing: 'running' },
161
- }
162
-
163
- function inflect(base: string, suffix: string): string {
164
- if (suffix === '') return base
165
- const irr = IRREGULAR[base]?.[suffix]
166
- if (irr != null) return irr
167
- if (suffix === 's' || suffix === 'es') return /(s|x|z|ch|sh)$/.test(base) ? `${base}es` : `${base}s`
168
- if (suffix === 'd' || suffix === 'ed') return base.endsWith('e') ? `${base}d` : `${base}ed`
169
- if (suffix === 'ing') return base.endsWith('e') ? `${base.slice(0, -1)}ing` : `${base}ing`
170
- return base
171
- }
172
-
173
- /** the same idea where the replacement must eat neighbouring punctuation.
174
- * 'However, X' becomes 'But X', never 'But, X'. */
175
- const PHRASE_SUBSTITUTIONS: { re: RegExp; to: string; why: string }[] = [
176
- { re: /\bhowever,\s+/gi, to: 'but ', why: "STE writes 'but'" },
177
- { re: /\bnevertheless,\s+/gi, to: 'but ', why: "STE writes 'but'" },
178
- { re: /\bnonetheless,\s+/gi, to: 'but ', why: "STE writes 'but'" },
179
- ]
180
-
181
- /** deletable: the word carries no fact, and removing it leaves valid grammar.
182
- * Intensifiers are deliberately NOT here. `very` is an approved STE word, and
183
- * an intensifier a writer chose states a degree, so deleting it edits the
184
- * claim rather than the prose. */
185
- const DELETIONS = [
186
- 'simply', 'seamlessly', 'effortlessly', 'blazingly', 'basically', 'essentially',
187
- 'it is worth noting that', 'it is important to note that', 'needless to say',
188
- ]
189
-
190
- /** a real violation with no safe mechanical repair: the writer picks the fact */
191
- const VAGUE: Record<string, string> = {
192
- robust: 'delete it, or give the measurable property',
193
- powerful: 'delete it, or give the measurable property',
194
- comprehensive: 'delete it, or name what it covers',
195
- performant: 'delete it, or give the number',
196
- 'state-of-the-art': 'delete it',
197
- 'best-in-class': 'delete it',
198
- ensure: 'write "make sure that", or name the guarantee',
199
- etc: 'name the items, or write "and the rest of <the family>"',
200
- 'and/or': 'pick one, or write "X, or Y, or both"',
201
- 'as needed': 'state the condition',
202
- 'as necessary': 'state the condition',
203
- 'gracefully handles': 'say what it does',
204
- }
205
-
206
- const MODALS = ['should', 'would', 'may', 'might', 'could']
207
-
208
- const PARTICIPLES =
209
- 'written|built|done|made|kept|held|read|sent|shown|given|taken|known|seen|found|lost|run|set|put|told|left|drawn|thrown|caught|brought|bought|spent|meant|dealt|felt|gone|come|become|begun'
210
-
211
- // --- masking -------------------------------------------------------------
212
-
213
- const blankOut = (m: string): string => m.replace(/[^\n]/g, ' ')
214
-
215
- /** everything that is NOT house prose, blanked to spaces of the same length,
216
- * so every offset a regex reports still points at the real source.
217
- * Out of scope by the standard's own Untouchables plus the house exemptions:
218
- * frontmatter, code fences, inline code, double-quoted verbatim spans,
219
- * URLs and link targets, and `>` blockquotes (paste-ready copy). */
220
- export function maskProse(src: string): string {
221
- let m = src.replace(/^---\n[\s\S]*?\n---\n/, blankOut)
222
- m = m.replace(/```[\s\S]*?```/g, blankOut)
223
- m = m.replace(/`[^`\n]*`/g, blankOut)
224
- // a verbatim quote is spec, never house prose. The cap keeps one unpaired
225
- // quote from swallowing the rest of the page.
226
- m = m.replace(/"[^"]{0,800}"/g, blankOut)
227
- m = m.replace(/“[^”]{0,800}”/g, blankOut)
228
- // a verbatim quoted with SINGLE quotes, which house pages use as often as
229
- // double ones. The opening quote must follow a boundary, so an apostrophe
230
- // inside a word (`don't`) can never open a span and swallow the line.
231
- // a word-internal apostrophe (`you're`) stays INSIDE the span. Real
232
- // quotations are full of them, and a span that stops at the first one
233
- // masks nothing, after which the fixer rewrites the quotation.
234
- m = m.replace(/(^|[\s(\[])'(?:[^'\n]|(?<=\w)'(?=\w))*'(?=[\s.,;:!?)\]]|$)/gm, (full) => full[0] + blankOut(full.slice(1)))
235
- m = m.replace(/(^|[\s(\[])‘[^’\n]{0,300}’/gm, (full) => full[0] + blankOut(full.slice(1)))
236
- m = m.replace(/\]\([^)\n]*\)/g, blankOut)
237
- m = m.replace(/https?:\/\/\S+/g, blankOut)
238
- m = m.replace(/^[ \t]*>.*$/gm, blankOut)
239
- return m
240
- }
241
-
242
- /** `is unmaintained` is a state, not a passive voice: there is no verb `to
243
- * unmaintain`, so no actor exists to name. The `un-` prefix catches almost
244
- * the whole family; the rest are participles coined off a noun. */
245
- const COINED = new Set(['gitignored', 'backticked', 'sandboxed', 'siloed'])
246
- /** words that end in the two letters and were never a verb. `be + \w+ed` cannot
247
- * tell them from a participle, and it reported `is red` as a passive voice. */
248
- const NOT_PARTICIPLES = new Set(['red', 'tired', 'hundred', 'aged', 'learned', 'sacred', 'bed', 'need', 'feed', 'speed', 'seed', 'freed', 'agreed', 'indeed'])
249
- const isAdjectival = (participle: string): boolean => {
250
- const w = participle.toLowerCase()
251
- return (w.startsWith('un') && w.endsWith('ed')) || COINED.has(w) || NOT_PARTICIPLES.has(w)
252
- }
253
-
254
- /** a table row is structure: word classes still apply inside a cell, the
255
- * sentence, paragraph and semicolon rules do not. A cell is not a sentence,
256
- * so "write two sentences" asks for the table to be broken, and its items
257
- * are already comma separated, which leaves the semicolon as the only
258
- * separator the writer has left. */
259
- const isTableRow = (l: string): boolean => /^\s*\|/.test(l)
260
-
261
- /** a list item OPENS a new paragraph: a run of items with no blank line
262
- * between them is a list, never a wall of text, and counting the run as one
263
- * paragraph reported a five item list as 38 sentences. Both numbered forms,
264
- * at any indentation. */
265
- const isListItem = (l: string): boolean => /^\s*(?:[-*+]|\d+[.)])\s/.test(l)
266
-
267
- const isStructureLine = (l: string): boolean => isTableRow(l) || /^\s*#{1,6}\s/.test(l) || /^\s*[-|: ]+$/.test(l)
268
-
269
- // --- sentence segmentation ----------------------------------------------
270
-
271
- /** house prose puts one bullet per line and uses ` — ` as a SEPARATOR, never
272
- * as sentence punctuation (identity/writing-voice.md). Both are boundaries
273
- * here, so a route line is not read as one 40-word sentence. */
274
- const splitSentences = (line: string): { text: string; at: number }[] => {
275
- const out: { text: string; at: number }[] = []
276
- let at = 0
277
- for (const part of line.split(/(?<=[.!?])\s+|\s+[—–]\s+/)) {
278
- const i = line.indexOf(part, at)
279
- out.push({ text: part, at: i < 0 ? at : i })
280
- at = (i < 0 ? at : i) + part.length
281
- }
282
- return out
283
- }
284
-
285
- /** STE counts a code span, a command or a path as ONE word */
286
- const wordCount = (s: string): number =>
287
- s
288
- .replace(/`[^`]*`/g, 'x')
289
- .replace(/^\s*(?:[-*+]|\d+\.)\s+/, '')
290
- .replace(/[*_]/g, '')
291
- .split(/\s+/)
292
- .filter((w) => /[A-Za-z0-9]/.test(w)).length
293
-
294
- // --- the checker ---------------------------------------------------------
295
-
296
- const capitalizeLike = (sample: string, replacement: string): string =>
297
- /^[A-Z]/.test(sample) ? replacement.charAt(0).toUpperCase() + replacement.slice(1) : replacement
298
-
299
- const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
300
-
301
- /** one spelling for a word or phrase, so `Prior To` and `prior to` are the
302
- * same entry */
303
- export const normWord = (w: string): string => w.trim().toLowerCase().replace(/\s+/g, ' ')
304
-
305
- export function checkProse(src: string, opts: ProseOpts = {}): ProseHit[] {
306
- const allow = new Set(opts.allow ?? [])
307
- const allowWords = new Set((opts.words?.allow ?? []).map((a) => normWord(a.word)))
308
- const masked = maskProse(src)
309
- const hits: ProseHit[] = []
310
- // line index → offset, once
311
- const lineStart: number[] = [0]
312
- for (let i = 0; i < src.length; i++) if (src[i] === '\n') lineStart.push(i + 1)
313
- const lineOf = (off: number): number => {
314
- let lo = 0
315
- let hi = lineStart.length - 1
316
- while (lo < hi) {
317
- const mid = (lo + hi + 1) >> 1
318
- if (lineStart[mid]! <= off) lo = mid
319
- else hi = mid - 1
320
- }
321
- return lo + 1
322
- }
323
-
324
- const push = (cls: ProseClass, start: number, end: number, why: string, fix?: string, word?: string) => {
325
- if (allow.has(cls)) return
326
- const text = src.slice(start, end)
327
- if (allowWords.size > 0 && (allowWords.has(normWord(text)) || (word != null && allowWords.has(word)))) return
328
- hits.push({ line: lineOf(start), start, end, cls, tier: TIER[cls], text, fix, why, ...(word == null ? {} : { word }) })
329
- }
330
-
331
- /** the whole masked line an offset sits on, for the classes that need the
332
- * line's SHAPE (a table row, a list item) and not only the span */
333
- const lineAt = (off: number): string => {
334
- const from = masked.lastIndexOf('\n', off) + 1
335
- const to = masked.indexOf('\n', off)
336
- return masked.slice(from, to < 0 ? masked.length : to)
337
- }
338
-
339
- const scan = (re: RegExp, run: (m: RegExpExecArray) => void) => {
340
- re.lastIndex = 0
341
- let m: RegExpExecArray | null
342
- while ((m = re.exec(masked)) != null) {
343
- if (m[0].trim() === '') continue
344
- run(m)
345
- }
346
- }
347
-
348
- // --- fix tier
349
- scan(new RegExp(`\\b(${Object.keys(CONTRACTIONS).map(escapeRe).join('|')})`, 'gi'), (m) => {
350
- const full = CONTRACTIONS[m[0].toLowerCase()]
351
- if (full != null) push('contraction', m.index, m.index + m[0].length, 'STE keeps the full form', capitalizeLike(m[0], full), m[0].toLowerCase())
352
- })
353
- scan(new RegExp(`(?<![\\w.])(${Object.keys(ABBREV).map(escapeRe).join('|')})`, 'gi'), (m) => {
354
- const full = ABBREV[m[0].toLowerCase()]
355
- if (full != null) push('abbrev', m.index, m.index + m[0].length, 'write the words a non-native reader can read', capitalizeLike(m[0], full), m[0].toLowerCase())
356
- })
357
- // a verb entry matches its inflected forms too, and the ending rides onto
358
- // the replacement
359
- const verbs = SUBSTITUTIONS.filter((x) => x.verb === true)
360
- const plain = SUBSTITUTIONS.filter((x) => x.verb !== true)
361
- for (const e of verbs) {
362
- // a base ending in `e` drops it before `-ing` (leverage → leveraging),
363
- // so the stem is matched both ways
364
- const stem = e.from.endsWith('e') ? e.from.slice(0, -1) : e.from
365
- scan(new RegExp(`\\b(?:${escapeRe(e.from)}(es|s|ed|d)?|${escapeRe(stem)}(ing))\\b`, 'gi'), (m) => {
366
- const to = inflect(e.to, (m[1] ?? m[2] ?? '').toLowerCase())
367
- push('slop', m.index, m.index + m[0].length, `STE writes '${e.to}'`, capitalizeLike(m[0], to), e.from)
368
- })
369
- }
370
- scan(new RegExp(`\\b(${plain.map((x) => escapeRe(x.from)).join('|')})\\b`, 'gi'), (m) => {
371
- const e = plain.find((x) => x.from === m[0]!.toLowerCase())
372
- if (e != null) push('slop', m.index, m.index + m[0].length, `STE writes '${e.to}'`, capitalizeLike(m[0], e.to), e.from)
373
- })
374
- // a deletion gives back exactly one space, and WHICH one depends on what
375
- // follows: before punctuation the space in front goes with the word, or
376
- // the page keeps `the world : publish`. At the start of a line the fix is
377
- // withheld entirely, because deleting the first word moves the capital
378
- // onto the next one and that is the writer's call.
379
- scan(new RegExp(`( *)\\b(${DELETIONS.map(escapeRe).join('|')})\\b( *)`, 'gi'), (m) => {
380
- const lead = m[1] ?? ''
381
- let end = m.index + m[0].length
382
- const after = masked[end] ?? ''
383
- const before = masked[m.index - 1] ?? ''
384
- // inside an enumeration the word owns one of the two commas around it,
385
- // or deleting it leaves `Also,, X`
386
- if (before === ',' && after === ',') end += 1
387
- const punctuationFollows = /[.,:;!?)\]]/.test(after)
388
- const fix = lead === '' ? undefined : punctuationFollows ? '' : lead
389
- push('slop', m.index, end, 'the word carries no fact', fix, normWord(m[2]!))
390
- })
391
- for (const p of PHRASE_SUBSTITUTIONS)
392
- scan(new RegExp(p.re.source, 'gi'), (m) => push('slop', m.index, m.index + m[0].length, p.why, capitalizeLike(m[0], p.to), normWord(m[0].replace(/,\s*$/, ''))))
393
-
394
- // --- hard tier
395
- scan(new RegExp(`\\b(${MODALS.join('|')})\\b`, 'gi'), (m) =>
396
- push('modal', m.index, m.index + m[0].length, "a requirement is 'must', a possibility is 'can', a recommendation is a fact or it is deleted", undefined, m[0].toLowerCase()),
397
- )
398
- scan(/;/g, (m) => {
399
- if (isTableRow(lineAt(m.index))) return
400
- push('semicolon', m.index, m.index + 1, 'write two sentences')
401
- })
402
- scan(/,\s+(\w+ing)\b/g, (m) => {
403
- if (/^(nothing|something|anything|everything|during|string|thing)$/i.test(m[1]!)) return
404
- // an `-ing` word used as an ADJECTIVE (`, missing path`) is not the
405
- // participial clause the rule bans, and no regex can tell them apart.
406
- // Measured on a real wiki, roughly two in three hits were a noun
407
- // in an enumeration (`selection, loading, empty, error and the pager`)
408
- // rather than a clause, which is why this class is SOFT: a rule that
409
- // cries wolf gets switched off, and then it guards nothing.
410
- if (/^(missing|following|existing|remaining|leading|trailing|incoming|outgoing|ongoing|pending|willing|living|growing|surrounding|underlying)$/i.test(m[1]!)) return
411
- // `Renaming a file, moving a responsibility, or changing any contract`
412
- // is a coordinated list of gerund NOUNS, not the participial clause
413
- // the rule bans. The tell is a second gerund behind `and` or `or`.
414
- if (/\b(and|or)\s+\w+ing\b/.test(lineAt(m.index))) return
415
- push('comma-ing', m.index, m.index + m[0].length, "STE bans an '-ing' verb after a comma: start a new sentence, or write the relative clause")
416
- })
417
- scan(new RegExp(`\\b(has|have|had)\\s+(been|\\w+ed|${PARTICIPLES})\\b`, 'gi'), (m) =>
418
- push('perfect', m.index, m.index + m[0].length, 'STE uses simple tenses only', undefined, normWord(m[0])),
419
- )
420
- scan(new RegExp(`\\b(${Object.keys(VAGUE).map(escapeRe).join('|')})\\b`, 'gi'), (m) => {
421
- const why = VAGUE[m[0].toLowerCase()]
422
- if (why != null) push('vague', m.index, m.index + m[0].length, why, undefined, m[0].toLowerCase())
423
- })
424
- // the repo's own ban list: hard, because the repo named the word itself.
425
- // A house hit on the same span gives way, so one word reports once and
426
- // with the repo's own replacement
427
- const banned: [number, number][] = []
428
- for (const b of opts.words?.ban ?? []) {
429
- const w = normWord(b.word)
430
- if (w === '') continue
431
- const body = w.split(' ').map(escapeRe).join('\\s+')
432
- scan(new RegExp(`(?<![\\w'-])${body}(?![\\w'-])`, 'gi'), (m) => {
433
- if (allow.has('ban')) return
434
- banned.push([m.index, m.index + m[0].length])
435
- const why = `this repo refuses '${w}'${b.to != null && b.to !== '' ? `, write '${b.to}'` : ''}${b.reason != null && b.reason !== '' ? `: ${b.reason}` : ''}`
436
- push('ban', m.index, m.index + m[0].length, why, b.to != null && b.to !== '' ? capitalizeLike(m[0], b.to) : undefined, w)
437
- })
438
- }
439
-
440
- // --- soft tier, line by line
441
- const limit = opts.strict === true ? 20 : 25
442
- const lines = masked.split('\n')
443
- let paraStart = -1
444
- let paraSentences = 0
445
- const flushParagraph = (endLine: number) => {
446
- if (paraStart >= 0 && paraSentences > 6)
447
- push('paragraph', lineStart[paraStart]!, lineStart[paraStart]! + (lines[paraStart]?.length ?? 0), `${paraSentences} sentences in one paragraph: STE allows six`)
448
- paraStart = -1
449
- paraSentences = 0
450
- void endLine
451
- }
452
- for (let i = 0; i < lines.length; i++) {
453
- const line = lines[i]!
454
- const off = lineStart[i]!
455
- if (line.trim() === '') {
456
- flushParagraph(i)
457
- continue
458
- }
459
- if (isStructureLine(line)) {
460
- flushParagraph(i)
461
- continue
462
- }
463
- // passive voice: a form of `be` plus a past participle
464
- const passRe = new RegExp(`\\b(is|are|was|were|be|been|being)\\s+(\\w+ed|${PARTICIPLES})\\b`, 'gi')
465
- let pm: RegExpExecArray | null
466
- while ((pm = passRe.exec(line)) != null) {
467
- if (isAdjectival(pm[2]!)) continue
468
- push('passive', off + pm.index, off + pm.index + pm[0].length, 'STE wants the active voice: name the actor', undefined, normWord(pm[0]))
469
- }
470
- const item = isListItem(line)
471
- if (item) flushParagraph(i)
472
- else if (paraStart < 0) paraStart = i
473
- for (const s of splitSentences(line)) {
474
- const w = wordCount(s.text)
475
- if (!item && paraStart >= 0 && w > 0) paraSentences++
476
- if (w > limit)
477
- push('sentence', off + s.at, off + s.at + s.text.length, `${w} words: the limit is ${limit}`)
478
- }
479
- }
480
- flushParagraph(lines.length)
481
-
482
- if (banned.length > 0) {
483
- const underBan = (h: ProseHit): boolean => h.cls !== 'ban' && h.word != null && banned.some(([a, b]) => h.start < b && a < h.end)
484
- for (let i = hits.length - 1; i >= 0; i--) if (underBan(hits[i]!)) hits.splice(i, 1)
485
- }
486
- return hits.sort((a, b) => a.start - b.start)
487
- }
488
-
489
- /** apply every mechanical rewrite, left to right, first hit wins.
490
- *
491
- * Two scans can claim the same characters: the deletion scan takes the space
492
- * in front of its word, and a phrase substitution takes the space behind the
493
- * comma it eats. Writing both over each other ate the text between them
494
- * (`However, simply.` came back as `But `). So a hit that starts inside an
495
- * already-applied span is DROPPED and the next pass picks it up on clean
496
- * text, which is why this runs to a FIXED POINT rather than once. Bounded,
497
- * because a fixer that cannot converge must say so rather than spin. */
498
- export function fixProse(src: string, opts: ProseOpts = {}): { text: string; applied: number } {
499
- let text = src
500
- let applied = 0
501
- for (let pass = 0; pass < 5; pass++) {
502
- const hits = checkProse(text, opts).filter((h) => h.fix != null)
503
- if (hits.length === 0) break
504
- let out = ''
505
- let cursor = 0
506
- let wrote = 0
507
- for (const h of [...hits].sort((a, b) => a.start - b.start)) {
508
- if (h.start < cursor) continue
509
- out += text.slice(cursor, h.start) + h.fix
510
- cursor = h.end
511
- wrote++
512
- }
513
- if (wrote === 0) break
514
- text = out + text.slice(cursor)
515
- applied += wrote
516
- }
517
- return { text, applied }
518
- }
519
-
520
- /** what is wrong with a repo's word lists, judged the same way by the config
521
- * loader and by the hub's writer: an empty word, a word listed twice, or a
522
- * word both allowed and banned, which leaves the repo's intent undecidable */
523
- export function proseWordsProblems(w: ProseWords | undefined): string[] {
524
- if (w == null) return []
525
- if (typeof w !== 'object' || Array.isArray(w)) return ['prose.words is an object: { allow?: [...], ban?: [...] }']
526
- const out: string[] = []
527
- for (const k of Object.keys(w)) if (k !== 'allow' && k !== 'ban') out.push(`prose.words.${k} is not a key (keys: allow, ban)`)
528
- const seen = new Map<string, 'allow' | 'ban'>()
529
- for (const list of ['allow', 'ban'] as const) {
530
- const entries: unknown = w[list] ?? []
531
- if (!Array.isArray(entries)) {
532
- out.push(`prose.words.${list} is a list`)
533
- continue
534
- }
535
- for (const e of entries as { word?: unknown; to?: unknown; reason?: unknown }[]) {
536
- if (e == null || typeof e.word !== 'string' || normWord(e.word) === '') {
537
- out.push(`prose.words.${list} has an entry with no word`)
538
- continue
539
- }
540
- const fields = list === 'ban' ? ['word', 'to', 'reason'] : ['word', 'reason']
541
- for (const f of Object.keys(e)) if (!fields.includes(f)) out.push(`prose.words.${list} '${e.word}' has an unknown field '${f}' (fields: ${fields.join(', ')})`)
542
- for (const f of ['to', 'reason'] as const) if (e[f] != null && typeof e[f] !== 'string') out.push(`prose.words.${list} '${e.word}' ${f} is a string`)
543
- const n = normWord(e.word)
544
- const was = seen.get(n)
545
- if (was === list) out.push(`prose.words.${list} lists '${n}' twice`)
546
- else if (was != null) out.push(`prose.words lists '${n}' in both allow and ban, pick one`)
547
- seen.set(n, list)
548
- }
549
- }
550
- return out
551
- }
552
-
@@ -1,24 +0,0 @@
1
- {
2
- // the hub is the one process every wired repo starts, so it opens with the
3
- // folder itself. It runs every check after each save and its problem
4
- // matcher fills the problems pane, so there is no type checker task. A
5
- // stack that earns more long-running watchers (dev server, css) adds a
6
- // master task with runOn folderOpen whose dependsOn lists them all, the hub
7
- // included, and moves runOn off the hub.
8
- "version": "2.0.0",
9
- "tasks": [
10
- {
11
- "label": "🚀 shipkit",
12
- "detail": "The shipkit hub at http://localhost:{{PORT}}, and every check's findings in the problems pane — clickable URLs: the page, /graph/wiki, /graph/agent",
13
- "type": "process",
14
- "command": "bun",
15
- "args": ["run", "hub"],
16
- "options": { "cwd": "${workspaceFolder}" },
17
- "isBackground": true,
18
- "presentation": { "reveal": "always", "close": false },
19
- "group": { "kind": "build", "isDefault": true },
20
- "runOptions": { "runOn": "folderOpen", "instanceLimit": 1 },
21
- "problemMatcher": {{HUB_PROBLEM_MATCHER}}
22
- }
23
- ]
24
- }