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
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
|
-
}
|