agent-simple-english 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +23 -0
- package/.claude-plugin/plugin.json +12 -0
- package/LICENSE +21 -0
- package/README.md +435 -0
- package/THIRD_PARTY_NOTICES.md +13 -0
- package/commands/ste.md +13 -0
- package/hooks/hooks.json +58 -0
- package/package.json +64 -0
- package/src/adapter/commit-message.ts +472 -0
- package/src/adapter/feedback.ts +40 -0
- package/src/adapter/rule-summary.ts +79 -0
- package/src/cli/hook.ts +681 -0
- package/src/cli/main.ts +201 -0
- package/src/cli/session-command.ts +78 -0
- package/src/cli/session-state.ts +214 -0
- package/src/config/load.ts +85 -0
- package/src/config/merge.ts +20 -0
- package/src/config/schema.ts +69 -0
- package/src/dictionary/README.md +18 -0
- package/src/dictionary/data/pi-ste.json +200 -0
- package/src/dictionary/form.ts +6 -0
- package/src/dictionary/load.ts +54 -0
- package/src/dictionary/schema.ts +28 -0
- package/src/engine/comments.ts +386 -0
- package/src/engine/diff.ts +328 -0
- package/src/engine/identifiers.ts +20 -0
- package/src/engine/kinds.ts +43 -0
- package/src/engine/lint.ts +530 -0
- package/src/engine/markdown.ts +338 -0
- package/src/engine/paragraphs.ts +105 -0
- package/src/engine/rules/contraction.ts +19 -0
- package/src/engine/rules/dictionary.ts +281 -0
- package/src/engine/rules/hedging.ts +27 -0
- package/src/engine/rules/marketing.ts +71 -0
- package/src/engine/rules/paragraph-length.ts +23 -0
- package/src/engine/rules/phrasal-verb.ts +57 -0
- package/src/engine/rules/registry.ts +15 -0
- package/src/engine/rules/semicolon.ts +14 -0
- package/src/engine/rules/sentence-length.ts +24 -0
- package/src/engine/rules/verb-form.ts +76 -0
- package/src/engine/scan.ts +15 -0
- package/src/engine/sentences.ts +285 -0
- package/src/engine/tagger.ts +8 -0
- package/src/engine/tokens.ts +2 -0
- package/src/engine/types.ts +45 -0
- package/src/extension/index.ts +755 -0
- package/src/tagger/wink.ts +44 -0
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
interface WordToken {
|
|
2
|
+
readonly type: "word"
|
|
3
|
+
readonly value: string
|
|
4
|
+
readonly dynamic: boolean
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
interface OperatorToken {
|
|
8
|
+
readonly type: "operator"
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface Heredoc {
|
|
12
|
+
readonly delimiter: string
|
|
13
|
+
readonly stripTabs: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type ShellToken = WordToken | OperatorToken
|
|
17
|
+
|
|
18
|
+
export type CommitInvocation =
|
|
19
|
+
| { readonly message: string; readonly requiresExplicitMessage: false }
|
|
20
|
+
| { readonly requiresExplicitMessage: true }
|
|
21
|
+
|
|
22
|
+
interface AnsiEscape {
|
|
23
|
+
readonly value: string
|
|
24
|
+
readonly width: number
|
|
25
|
+
readonly extractable: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function numericEscape(command: string, index: number, pattern: RegExp, limit: number): AnsiEscape {
|
|
29
|
+
const digits = command.slice(index + 1, index + 1 + limit).match(pattern)?.[0] ?? ""
|
|
30
|
+
if (digits.length === 0) {
|
|
31
|
+
return { value: `\\${command[index] ?? ""}`, width: 1, extractable: true }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const codePoint = Number.parseInt(digits, 16)
|
|
35
|
+
const validCodePoint = codePoint <= 0x10ffff && !(codePoint >= 0xd800 && codePoint <= 0xdfff)
|
|
36
|
+
return {
|
|
37
|
+
value: validCodePoint ? String.fromCodePoint(codePoint) : "",
|
|
38
|
+
width: digits.length + 1,
|
|
39
|
+
extractable: validCodePoint,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function ansiEscape(command: string, index: number): AnsiEscape {
|
|
44
|
+
const character = command[index] ?? ""
|
|
45
|
+
const escapes: Readonly<Record<string, string>> = {
|
|
46
|
+
a: "\x07",
|
|
47
|
+
b: "\b",
|
|
48
|
+
e: "\x1b",
|
|
49
|
+
E: "\x1b",
|
|
50
|
+
f: "\f",
|
|
51
|
+
n: "\n",
|
|
52
|
+
r: "\r",
|
|
53
|
+
t: "\t",
|
|
54
|
+
v: "\v",
|
|
55
|
+
"\\": "\\",
|
|
56
|
+
"'": "'",
|
|
57
|
+
'"': '"',
|
|
58
|
+
"?": "?",
|
|
59
|
+
}
|
|
60
|
+
const escaped = escapes[character]
|
|
61
|
+
if (escaped !== undefined) return { value: escaped, width: 1, extractable: true }
|
|
62
|
+
|
|
63
|
+
if (/[0-7]/u.test(character)) {
|
|
64
|
+
const digits = command.slice(index, index + 3).match(/^[0-7]{1,3}/u)?.[0] ?? character
|
|
65
|
+
return {
|
|
66
|
+
value: String.fromCharCode(Number.parseInt(digits, 8) & 0xff),
|
|
67
|
+
width: digits.length,
|
|
68
|
+
extractable: true,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (character === "x") return numericEscape(command, index, /^[0-9A-Fa-f]{1,2}/u, 2)
|
|
72
|
+
if (character === "u") return numericEscape(command, index, /^[0-9A-Fa-f]{1,4}/u, 4)
|
|
73
|
+
if (character === "U") return numericEscape(command, index, /^[0-9A-Fa-f]{1,8}/u, 8)
|
|
74
|
+
if (character === "c" && command[index + 1] !== undefined) {
|
|
75
|
+
const controlled = command[index + 1] ?? ""
|
|
76
|
+
const codePoint = controlled === "?" ? 0x7f : controlled.toUpperCase().charCodeAt(0) & 0x1f
|
|
77
|
+
return { value: String.fromCharCode(codePoint), width: 2, extractable: true }
|
|
78
|
+
}
|
|
79
|
+
return { value: `\\${character}`, width: 1, extractable: true }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function tokenize(command: string): ShellToken[] {
|
|
83
|
+
const tokens: ShellToken[] = []
|
|
84
|
+
let value = ""
|
|
85
|
+
let dynamic = false
|
|
86
|
+
let started = false
|
|
87
|
+
let discardWord = false
|
|
88
|
+
let heredocDeclaration: Pick<Heredoc, "stripTabs"> | undefined
|
|
89
|
+
const heredocs: Heredoc[] = []
|
|
90
|
+
let index = 0
|
|
91
|
+
|
|
92
|
+
const flush = () => {
|
|
93
|
+
if (!started) return
|
|
94
|
+
if (!discardWord) tokens.push({ type: "word", value, dynamic })
|
|
95
|
+
else if (heredocDeclaration !== undefined) {
|
|
96
|
+
heredocs.push({ delimiter: value, stripTabs: heredocDeclaration.stripTabs })
|
|
97
|
+
}
|
|
98
|
+
value = ""
|
|
99
|
+
dynamic = false
|
|
100
|
+
started = false
|
|
101
|
+
discardWord = false
|
|
102
|
+
heredocDeclaration = undefined
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const redirection = (redirectionOperator: string) => {
|
|
106
|
+
if (started && /^\d+$/u.test(value)) {
|
|
107
|
+
value = ""
|
|
108
|
+
dynamic = false
|
|
109
|
+
started = false
|
|
110
|
+
} else {
|
|
111
|
+
flush()
|
|
112
|
+
}
|
|
113
|
+
discardWord = true
|
|
114
|
+
heredocDeclaration =
|
|
115
|
+
redirectionOperator === "<<" || redirectionOperator === "<<-"
|
|
116
|
+
? { stripTabs: redirectionOperator === "<<-" }
|
|
117
|
+
: undefined
|
|
118
|
+
index += redirectionOperator.length
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const operator = (width = 1) => {
|
|
122
|
+
flush()
|
|
123
|
+
discardWord = false
|
|
124
|
+
heredocDeclaration = undefined
|
|
125
|
+
tokens.push({ type: "operator" })
|
|
126
|
+
index += width
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
while (index < command.length) {
|
|
130
|
+
if (heredocs.length > 0 && (index === 0 || command[index - 1] === "\n")) {
|
|
131
|
+
const lineEnd = command.indexOf("\n", index)
|
|
132
|
+
const end = lineEnd === -1 ? command.length : lineEnd
|
|
133
|
+
const line = command.slice(index, end).replace(/\r$/u, "")
|
|
134
|
+
const activeHeredoc = heredocs[0]
|
|
135
|
+
const candidate = activeHeredoc?.stripTabs ? line.replace(/^\t+/u, "") : line
|
|
136
|
+
if (candidate === activeHeredoc?.delimiter) heredocs.shift()
|
|
137
|
+
index = lineEnd === -1 ? command.length : lineEnd + 1
|
|
138
|
+
continue
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const character = command[index] ?? ""
|
|
142
|
+
|
|
143
|
+
if (/[^\S\r\n]/u.test(character) || character === "\r") {
|
|
144
|
+
flush()
|
|
145
|
+
index++
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
if (character === "\n") {
|
|
149
|
+
operator()
|
|
150
|
+
continue
|
|
151
|
+
}
|
|
152
|
+
if (character === ";" || character === "(" || character === ")") {
|
|
153
|
+
operator()
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
if (
|
|
157
|
+
(character === "{" || character === "}") &&
|
|
158
|
+
!started &&
|
|
159
|
+
(command[index + 1] === undefined || /[\s;&|()]/u.test(command[index + 1] ?? ""))
|
|
160
|
+
) {
|
|
161
|
+
operator()
|
|
162
|
+
continue
|
|
163
|
+
}
|
|
164
|
+
if (
|
|
165
|
+
character === "<" ||
|
|
166
|
+
character === ">" ||
|
|
167
|
+
(character === "&" && command[index + 1] === ">")
|
|
168
|
+
) {
|
|
169
|
+
const rest = command.slice(index)
|
|
170
|
+
const redirectionOperator =
|
|
171
|
+
rest.match(/^(?:<<<|<<-|&>>|<<|<>|<&|>>|>\||>&|&>|<|>)/u)?.[0] ?? character
|
|
172
|
+
redirection(redirectionOperator)
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
175
|
+
if (character === "&" || character === "|") {
|
|
176
|
+
operator(command[index + 1] === character ? 2 : 1)
|
|
177
|
+
continue
|
|
178
|
+
}
|
|
179
|
+
if (character === "#" && !started) {
|
|
180
|
+
while (index < command.length && command[index] !== "\n") index++
|
|
181
|
+
continue
|
|
182
|
+
}
|
|
183
|
+
if (character === "\\") {
|
|
184
|
+
started = true
|
|
185
|
+
const next = command[index + 1]
|
|
186
|
+
if (next === "\n") {
|
|
187
|
+
index += 2
|
|
188
|
+
} else if (next === undefined) {
|
|
189
|
+
value += "\\"
|
|
190
|
+
index++
|
|
191
|
+
} else {
|
|
192
|
+
value += next
|
|
193
|
+
index += 2
|
|
194
|
+
}
|
|
195
|
+
continue
|
|
196
|
+
}
|
|
197
|
+
if (character === "'") {
|
|
198
|
+
started = true
|
|
199
|
+
index++
|
|
200
|
+
while (index < command.length && command[index] !== "'") {
|
|
201
|
+
value += command[index]
|
|
202
|
+
index++
|
|
203
|
+
}
|
|
204
|
+
if (command[index] === "'") index++
|
|
205
|
+
continue
|
|
206
|
+
}
|
|
207
|
+
if (character === '"') {
|
|
208
|
+
started = true
|
|
209
|
+
index++
|
|
210
|
+
while (index < command.length && command[index] !== '"') {
|
|
211
|
+
const quoted = command[index] ?? ""
|
|
212
|
+
if (quoted === "\\") {
|
|
213
|
+
const next = command[index + 1]
|
|
214
|
+
if (next === "\n") {
|
|
215
|
+
index += 2
|
|
216
|
+
continue
|
|
217
|
+
}
|
|
218
|
+
if (next === "$" || next === "`" || next === '"' || next === "\\") {
|
|
219
|
+
value += next
|
|
220
|
+
index += 2
|
|
221
|
+
continue
|
|
222
|
+
}
|
|
223
|
+
value += "\\"
|
|
224
|
+
index++
|
|
225
|
+
continue
|
|
226
|
+
}
|
|
227
|
+
if (quoted === "$" || quoted === "`") dynamic = true
|
|
228
|
+
value += quoted
|
|
229
|
+
index++
|
|
230
|
+
}
|
|
231
|
+
if (command[index] === '"') index++
|
|
232
|
+
continue
|
|
233
|
+
}
|
|
234
|
+
if (character === "$" && command[index + 1] === "'") {
|
|
235
|
+
started = true
|
|
236
|
+
index += 2
|
|
237
|
+
while (index < command.length && command[index] !== "'") {
|
|
238
|
+
const quoted = command[index] ?? ""
|
|
239
|
+
if (quoted === "\\" && command[index + 1] !== undefined) {
|
|
240
|
+
const ansiSequence = ansiEscape(command, index + 1)
|
|
241
|
+
value += ansiSequence.value
|
|
242
|
+
dynamic ||= !ansiSequence.extractable
|
|
243
|
+
index += ansiSequence.width + 1
|
|
244
|
+
} else {
|
|
245
|
+
value += quoted
|
|
246
|
+
index++
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (command[index] === "'") index++
|
|
250
|
+
continue
|
|
251
|
+
}
|
|
252
|
+
if (
|
|
253
|
+
character === "$" ||
|
|
254
|
+
character === "`" ||
|
|
255
|
+
character === "*" ||
|
|
256
|
+
character === "?" ||
|
|
257
|
+
character === "[" ||
|
|
258
|
+
character === "{" ||
|
|
259
|
+
character === "}" ||
|
|
260
|
+
(character === "~" && !started)
|
|
261
|
+
) {
|
|
262
|
+
dynamic = true
|
|
263
|
+
}
|
|
264
|
+
started = true
|
|
265
|
+
value += character
|
|
266
|
+
index++
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
flush()
|
|
270
|
+
return tokens
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function commandSegments(tokens: readonly ShellToken[]): WordToken[][] {
|
|
274
|
+
const segments: WordToken[][] = []
|
|
275
|
+
let segment: WordToken[] = []
|
|
276
|
+
for (const token of tokens) {
|
|
277
|
+
if (token.type === "operator") {
|
|
278
|
+
if (segment.length > 0) segments.push(segment)
|
|
279
|
+
segment = []
|
|
280
|
+
} else {
|
|
281
|
+
segment.push(token)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (segment.length > 0) segments.push(segment)
|
|
285
|
+
return segments
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function executableName(value: string): string {
|
|
289
|
+
return value.slice(Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\")) + 1)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function gitCommandIndex(segment: readonly WordToken[]): number | undefined {
|
|
293
|
+
let index = 0
|
|
294
|
+
if (segment[index]?.value === "command") {
|
|
295
|
+
index++
|
|
296
|
+
while (segment[index]?.value.startsWith("-")) index++
|
|
297
|
+
}
|
|
298
|
+
if (segment[index]?.value === "env") {
|
|
299
|
+
index++
|
|
300
|
+
while (segment[index]) {
|
|
301
|
+
const argument = segment[index]
|
|
302
|
+
if (
|
|
303
|
+
argument === undefined ||
|
|
304
|
+
(!argument.value.startsWith("-") && !/^[A-Za-z_][A-Za-z0-9_]*=/u.test(argument.value))
|
|
305
|
+
) {
|
|
306
|
+
break
|
|
307
|
+
}
|
|
308
|
+
index++
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
while (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(segment[index]?.value ?? "")) index++
|
|
312
|
+
return executableName(segment[index]?.value ?? "") === "git" ? index : undefined
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function commitSubcommandIndex(
|
|
316
|
+
segment: readonly WordToken[],
|
|
317
|
+
gitIndex: number,
|
|
318
|
+
): number | undefined {
|
|
319
|
+
for (let index = gitIndex + 1; index < segment.length; index++) {
|
|
320
|
+
const argument = segment[index]?.value ?? ""
|
|
321
|
+
if (argument === "commit") return index
|
|
322
|
+
if (!argument.startsWith("-")) return undefined
|
|
323
|
+
if (
|
|
324
|
+
["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--config-env"].includes(argument)
|
|
325
|
+
) {
|
|
326
|
+
index++
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return undefined
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const SHORT_OPTIONS_WITHOUT_VALUE = new Set(["a", "p", "s", "n", "e", "i", "o", "v", "q", "z"])
|
|
333
|
+
const SHORT_OPTIONS_WITH_VALUE = new Set(["C", "c", "F", "m", "t", "u"])
|
|
334
|
+
const LONG_OPTIONS_WITH_VALUE = new Set([
|
|
335
|
+
"--author",
|
|
336
|
+
"--cleanup",
|
|
337
|
+
"--date",
|
|
338
|
+
"--file",
|
|
339
|
+
"--fixup",
|
|
340
|
+
"--message",
|
|
341
|
+
"--pathspec-from-file",
|
|
342
|
+
"--reedit-message",
|
|
343
|
+
"--reuse-message",
|
|
344
|
+
"--squash",
|
|
345
|
+
"--template",
|
|
346
|
+
"--trailer",
|
|
347
|
+
])
|
|
348
|
+
|
|
349
|
+
type ShortOption =
|
|
350
|
+
| { readonly type: "message"; readonly attached: string }
|
|
351
|
+
| { readonly type: "next-message" }
|
|
352
|
+
| { readonly type: "skip-next" }
|
|
353
|
+
| { readonly type: "other" }
|
|
354
|
+
|
|
355
|
+
function classifyShortOption(argument: string): ShortOption {
|
|
356
|
+
if (argument === "-u") return { type: "other" }
|
|
357
|
+
|
|
358
|
+
for (let index = 1; index < argument.length; index++) {
|
|
359
|
+
const option = argument[index] ?? ""
|
|
360
|
+
if (SHORT_OPTIONS_WITHOUT_VALUE.has(option)) continue
|
|
361
|
+
if (option === "S") return { type: "other" }
|
|
362
|
+
if (!SHORT_OPTIONS_WITH_VALUE.has(option)) return { type: "other" }
|
|
363
|
+
|
|
364
|
+
const attached = argument.slice(index + 1)
|
|
365
|
+
if (option === "m") {
|
|
366
|
+
return attached.length > 0 ? { type: "message", attached } : { type: "next-message" }
|
|
367
|
+
}
|
|
368
|
+
return attached.length > 0 ? { type: "other" } : { type: "skip-next" }
|
|
369
|
+
}
|
|
370
|
+
return { type: "other" }
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function invocation(segment: readonly WordToken[]): CommitInvocation | undefined {
|
|
374
|
+
const gitIndex = gitCommandIndex(segment)
|
|
375
|
+
if (gitIndex === undefined) return undefined
|
|
376
|
+
const commitIndex = commitSubcommandIndex(segment, gitIndex)
|
|
377
|
+
if (commitIndex === undefined) return undefined
|
|
378
|
+
|
|
379
|
+
const messageParts: string[] = []
|
|
380
|
+
let messageIsDynamic = false
|
|
381
|
+
|
|
382
|
+
for (let index = commitIndex + 1; index < segment.length; index++) {
|
|
383
|
+
const argument = segment[index]
|
|
384
|
+
if (argument === undefined) continue
|
|
385
|
+
if (argument.value === "--") break
|
|
386
|
+
if (argument.value === "-m" || argument.value === "--message") {
|
|
387
|
+
const message = segment[++index]
|
|
388
|
+
if (message === undefined) return { requiresExplicitMessage: true }
|
|
389
|
+
messageParts.push(message.value)
|
|
390
|
+
messageIsDynamic ||= message.dynamic
|
|
391
|
+
continue
|
|
392
|
+
}
|
|
393
|
+
if (argument.value.startsWith("--message=")) {
|
|
394
|
+
messageParts.push(argument.value.slice("--message=".length))
|
|
395
|
+
messageIsDynamic ||= argument.dynamic
|
|
396
|
+
continue
|
|
397
|
+
}
|
|
398
|
+
if (LONG_OPTIONS_WITH_VALUE.has(argument.value)) {
|
|
399
|
+
index++
|
|
400
|
+
continue
|
|
401
|
+
}
|
|
402
|
+
if (!argument.value.startsWith("-") || argument.value.startsWith("--")) continue
|
|
403
|
+
|
|
404
|
+
const shortOption = classifyShortOption(argument.value)
|
|
405
|
+
if (shortOption.type === "skip-next") {
|
|
406
|
+
index++
|
|
407
|
+
continue
|
|
408
|
+
}
|
|
409
|
+
if (shortOption.type === "other") continue
|
|
410
|
+
if (shortOption.type === "message") {
|
|
411
|
+
messageParts.push(shortOption.attached)
|
|
412
|
+
messageIsDynamic ||= argument.dynamic
|
|
413
|
+
continue
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const message = segment[++index]
|
|
417
|
+
if (message === undefined) return { requiresExplicitMessage: true }
|
|
418
|
+
messageParts.push(message.value)
|
|
419
|
+
messageIsDynamic ||= message.dynamic
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (messageParts.length > 0) {
|
|
423
|
+
return messageIsDynamic
|
|
424
|
+
? { requiresExplicitMessage: true }
|
|
425
|
+
: { message: messageParts.join("\n\n"), requiresExplicitMessage: false }
|
|
426
|
+
}
|
|
427
|
+
return { requiresExplicitMessage: true }
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export function findCommitInvocations(command: string): readonly CommitInvocation[] {
|
|
431
|
+
return commandSegments(tokenize(command)).flatMap((segment) => {
|
|
432
|
+
const found = invocation(segment)
|
|
433
|
+
return found === undefined ? [] : [found]
|
|
434
|
+
})
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function blankLine(line: string): string {
|
|
438
|
+
return " ".repeat(line.length)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export function blankCommitMetadata(message: string): string {
|
|
442
|
+
const lines = message.split("\n")
|
|
443
|
+
const prefix = lines[0]?.match(/^[A-Za-z][A-Za-z0-9-]*(?:\([^\r\n)]*\))?!?:[\t ]*/u)?.[0]
|
|
444
|
+
if (prefix !== undefined && lines[0] !== undefined) {
|
|
445
|
+
lines[0] = `:${" ".repeat(Math.max(prefix.length - 1, 0))}${lines[0].slice(prefix.length)}`
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
let trailerEnd = lines.length - 1
|
|
449
|
+
while (trailerEnd > 0 && !/\S/u.test(lines[trailerEnd] ?? "")) trailerEnd--
|
|
450
|
+
let trailerStart = trailerEnd + 1
|
|
451
|
+
let hasTrailer = false
|
|
452
|
+
for (let index = trailerEnd; index > 0; index--) {
|
|
453
|
+
const line = lines[index] ?? ""
|
|
454
|
+
if (/^(?:[A-Za-z0-9][A-Za-z0-9-]*|BREAKING CHANGE)[\t ]*(?::| #)[\t ]+\S/u.test(line)) {
|
|
455
|
+
hasTrailer = true
|
|
456
|
+
trailerStart = index
|
|
457
|
+
continue
|
|
458
|
+
}
|
|
459
|
+
if (/^[\t ]+\S/u.test(line)) {
|
|
460
|
+
trailerStart = index
|
|
461
|
+
continue
|
|
462
|
+
}
|
|
463
|
+
break
|
|
464
|
+
}
|
|
465
|
+
if (hasTrailer) {
|
|
466
|
+
for (let index = trailerStart; index <= trailerEnd; index++) {
|
|
467
|
+
lines[index] = blankLine(lines[index] ?? "")
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return lines.join("\n")
|
|
472
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { RuleId } from "../engine/rules/registry.ts"
|
|
2
|
+
import type { Violation } from "../engine/types.ts"
|
|
3
|
+
|
|
4
|
+
function suggestedFix(violation: Violation): string {
|
|
5
|
+
if (violation.suggestion !== undefined) return `Use "${violation.suggestion}".`
|
|
6
|
+
if (violation.suggestions !== undefined && violation.suggestions.length > 0) {
|
|
7
|
+
return `Use one of these approved alternatives: ${violation.suggestions.map((item) => `"${item}"`).join(", ")}.`
|
|
8
|
+
}
|
|
9
|
+
const fixes: Readonly<Record<RuleId, string>> = {
|
|
10
|
+
contraction: "Write the contracted words in full.",
|
|
11
|
+
"dictionary-not-approved-word": "Replace the unapproved word with an approved alternative.",
|
|
12
|
+
hedging: "Delete the hedging phrase.",
|
|
13
|
+
marketing: "Replace the phrase with factual language.",
|
|
14
|
+
"paragraph-length": "Split the paragraph into shorter paragraphs.",
|
|
15
|
+
"phrasal-verb": "Replace the phrasal verb with one approved verb.",
|
|
16
|
+
semicolon: "Replace the semicolon with a full stop and write two sentences.",
|
|
17
|
+
"sentence-length": "Split the sentence into shorter sentences.",
|
|
18
|
+
"verb-progressive": "Use a permitted simple verb form.",
|
|
19
|
+
"verb-passive": "Name the actor and use active voice.",
|
|
20
|
+
"verb-perfect": "Use a permitted simple verb form.",
|
|
21
|
+
}
|
|
22
|
+
return fixes[violation.ruleId]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function violationDetails(violations: readonly Violation[]): string {
|
|
26
|
+
return violations
|
|
27
|
+
.map(
|
|
28
|
+
(violation) =>
|
|
29
|
+
`- line ${violation.line}, column ${violation.column} [${violation.ruleId}]: ${violation.message} Suggested fix: ${suggestedFix(violation)}`,
|
|
30
|
+
)
|
|
31
|
+
.join("\n")
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function formatViolations(
|
|
35
|
+
path: string,
|
|
36
|
+
heading: string,
|
|
37
|
+
violations: readonly Violation[],
|
|
38
|
+
): string {
|
|
39
|
+
return `${heading} ${path}:\n${violationDetails(violations)}`
|
|
40
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { SteConfig } from "../config/schema.ts"
|
|
2
|
+
import { DEFAULT_MAX_SENTENCE_WORDS } from "../engine/lint.ts"
|
|
3
|
+
import type { RuleId } from "../engine/rules/registry.ts"
|
|
4
|
+
import type { RuleSetting } from "../engine/types.ts"
|
|
5
|
+
|
|
6
|
+
const DEFAULT_RULE_SETTINGS: Readonly<Record<RuleId, RuleSetting>> = {
|
|
7
|
+
contraction: "hard",
|
|
8
|
+
"dictionary-not-approved-word": "hard",
|
|
9
|
+
hedging: "soft",
|
|
10
|
+
marketing: "soft",
|
|
11
|
+
"paragraph-length": "hard",
|
|
12
|
+
"phrasal-verb": "hard",
|
|
13
|
+
semicolon: "hard",
|
|
14
|
+
"sentence-length": "hard",
|
|
15
|
+
"verb-progressive": "hard",
|
|
16
|
+
"verb-passive": "soft",
|
|
17
|
+
"verb-perfect": "hard",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const RULE_SUMMARIES: Readonly<Record<RuleId, string>> = {
|
|
21
|
+
contraction: "Do not use contractions. Write the words in full.",
|
|
22
|
+
"dictionary-not-approved-word": "Use approved words from the STE dictionary.",
|
|
23
|
+
hedging: "Remove hedging phrases.",
|
|
24
|
+
marketing: "Use factual language instead of marketing language.",
|
|
25
|
+
"paragraph-length": "Use no more than six sentences in one paragraph.",
|
|
26
|
+
"phrasal-verb": "Use an approved single-word verb instead of a phrasal verb.",
|
|
27
|
+
semicolon: "Do not use semicolons. Write two sentences.",
|
|
28
|
+
"sentence-length": "Keep each sentence within the configured word limit.",
|
|
29
|
+
"verb-progressive": "Do not use progressive verb forms.",
|
|
30
|
+
"verb-passive": "Prefer active voice.",
|
|
31
|
+
"verb-perfect": "Do not use perfect verb forms.",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function resolvedRuleSetting(config: SteConfig, ruleId: RuleId): RuleSetting {
|
|
35
|
+
return config.rules?.[ruleId] ?? DEFAULT_RULE_SETTINGS[ruleId]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function formatFailedStatusSummary(
|
|
39
|
+
mode: "disabled" | "enabled" | "strict",
|
|
40
|
+
configError: string,
|
|
41
|
+
): string {
|
|
42
|
+
return [
|
|
43
|
+
`Mode: ${mode}`,
|
|
44
|
+
`Config: failed (${configError})`,
|
|
45
|
+
"Rules: unavailable",
|
|
46
|
+
"Dictionary: unavailable",
|
|
47
|
+
].join("\n")
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function formatStatusSummary(
|
|
51
|
+
config: SteConfig,
|
|
52
|
+
mode: "disabled" | "enabled" | "strict",
|
|
53
|
+
dictionary: string,
|
|
54
|
+
): string {
|
|
55
|
+
const counts: Record<RuleSetting, number> = { hard: 0, soft: 0, off: 0 }
|
|
56
|
+
for (const ruleId of Object.keys(RULE_SUMMARIES) as RuleId[]) {
|
|
57
|
+
counts[resolvedRuleSetting(config, ruleId)] += 1
|
|
58
|
+
}
|
|
59
|
+
return [
|
|
60
|
+
`Mode: ${mode}`,
|
|
61
|
+
`Rules: ${counts.hard} hard, ${counts.soft} soft, ${counts.off} off`,
|
|
62
|
+
`Dictionary: ${dictionary}`,
|
|
63
|
+
].join("\n")
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function ruleSummary(config: SteConfig): string {
|
|
67
|
+
const maxSentenceWords = config.maxSentenceWords ?? DEFAULT_MAX_SENTENCE_WORDS
|
|
68
|
+
const rules = (Object.keys(RULE_SUMMARIES) as RuleId[])
|
|
69
|
+
.filter((ruleId) => resolvedRuleSetting(config, ruleId) !== "off")
|
|
70
|
+
.map((ruleId) => {
|
|
71
|
+
const summary =
|
|
72
|
+
ruleId === "sentence-length"
|
|
73
|
+
? `Keep each sentence to ${maxSentenceWords} words or fewer.`
|
|
74
|
+
: RULE_SUMMARIES[ruleId]
|
|
75
|
+
return `- [${resolvedRuleSetting(config, ruleId)}] ${summary}`
|
|
76
|
+
})
|
|
77
|
+
.join("\n")
|
|
78
|
+
return `## Simplified Technical English\n\nFollow these STE rules in prose that you write or edit:\n${rules}\n\nWrites, edits, and git commit messages reject hard violations. Correct the reported text and retry. Soft violations produce warnings.`
|
|
79
|
+
}
|