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,281 @@
|
|
|
1
|
+
import { DICTIONARY_TOKEN_SOURCE } from "../../dictionary/form.ts"
|
|
2
|
+
import type { Dictionary, DictionaryEntry } from "../../dictionary/schema.ts"
|
|
3
|
+
import type { TaggedToken, Tagger } from "../tagger.ts"
|
|
4
|
+
import type { Violation } from "../types.ts"
|
|
5
|
+
|
|
6
|
+
interface WordToken {
|
|
7
|
+
readonly text: string
|
|
8
|
+
readonly lower: string
|
|
9
|
+
readonly lineIndex: number
|
|
10
|
+
readonly offset: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface Form {
|
|
14
|
+
readonly entry: DictionaryEntry
|
|
15
|
+
readonly words: readonly string[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface MarkdownContext {
|
|
19
|
+
readonly contentStart: number
|
|
20
|
+
readonly quoteDepth: number
|
|
21
|
+
readonly paragraphId?: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface ActiveParagraph {
|
|
25
|
+
readonly id: number
|
|
26
|
+
readonly quoteDepth: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const ATX_HEADING = /^ {0,3}#{1,6}(?:[\t ]+|$)/
|
|
30
|
+
const LIST_MARKER = /^ {0,3}(?:[-+*]|\d{1,9}[.)])(?:[\t ]+|$)/
|
|
31
|
+
const SETEXT_UNDERLINE = /^ {0,3}(?:=+|-+)[\t ]*\r?$/
|
|
32
|
+
const THEMATIC_BREAK = /^ {0,3}(?:(?:\*[\t ]*){3,}|(?:_[\t ]*){3,}|(?:-[\t ]*){3,})\r?$/
|
|
33
|
+
|
|
34
|
+
const tokenize = (lines: readonly string[]): readonly WordToken[] => {
|
|
35
|
+
const tokenPattern = new RegExp(DICTIONARY_TOKEN_SOURCE, "gu")
|
|
36
|
+
return lines.flatMap((line, lineIndex) =>
|
|
37
|
+
Array.from(line.matchAll(tokenPattern), (match) => ({
|
|
38
|
+
text: match[0],
|
|
39
|
+
lower: match[0].toLowerCase(),
|
|
40
|
+
lineIndex,
|
|
41
|
+
offset: match.index,
|
|
42
|
+
})),
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const compileForms = (dictionary: Dictionary): readonly Form[] =>
|
|
47
|
+
dictionary.entries
|
|
48
|
+
.flatMap((entry) =>
|
|
49
|
+
entry.unapproved.map((form) => ({ entry, words: form.toLowerCase().split(/\s+/) })),
|
|
50
|
+
)
|
|
51
|
+
.sort((left, right) => right.words.length - left.words.length)
|
|
52
|
+
|
|
53
|
+
const markdownContext = (line: string, initialContentStart = 0): MarkdownContext => {
|
|
54
|
+
let contentStart = Math.min(initialContentStart, line.length)
|
|
55
|
+
let quoteDepth = 0
|
|
56
|
+
|
|
57
|
+
while (contentStart < line.length) {
|
|
58
|
+
let marker = contentStart
|
|
59
|
+
let spaces = 0
|
|
60
|
+
while (spaces < 4 && line[marker] === " ") {
|
|
61
|
+
marker++
|
|
62
|
+
spaces++
|
|
63
|
+
}
|
|
64
|
+
if (spaces > 3 || line[marker] !== ">") {
|
|
65
|
+
break
|
|
66
|
+
}
|
|
67
|
+
contentStart = marker + 1
|
|
68
|
+
if (line[contentStart] === " " || line[contentStart] === "\t") {
|
|
69
|
+
contentStart++
|
|
70
|
+
}
|
|
71
|
+
quoteDepth++
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { contentStart, quoteDepth }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const blockContent = (line: string, context: MarkdownContext): string =>
|
|
78
|
+
line.slice(context.contentStart)
|
|
79
|
+
|
|
80
|
+
const isLeafBlock = (content: string): boolean => {
|
|
81
|
+
const listMarker = content.match(LIST_MARKER)
|
|
82
|
+
const nestedContent = listMarker === null ? content : content.slice(listMarker[0].length)
|
|
83
|
+
return ATX_HEADING.test(nestedContent)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const startsNewBlock = (content: string): boolean =>
|
|
87
|
+
ATX_HEADING.test(content) ||
|
|
88
|
+
LIST_MARKER.test(content) ||
|
|
89
|
+
SETEXT_UNDERLINE.test(content) ||
|
|
90
|
+
THEMATIC_BREAK.test(content)
|
|
91
|
+
|
|
92
|
+
const isParagraphBlock = (content: string): boolean =>
|
|
93
|
+
!isLeafBlock(content) && !SETEXT_UNDERLINE.test(content) && !THEMATIC_BREAK.test(content)
|
|
94
|
+
|
|
95
|
+
const isIndentedCode = (content: string): boolean => /^(?: {4}|\t)/.test(content)
|
|
96
|
+
|
|
97
|
+
const markdownContexts = (
|
|
98
|
+
lines: readonly string[],
|
|
99
|
+
contentStarts: readonly number[],
|
|
100
|
+
): readonly MarkdownContext[] => {
|
|
101
|
+
let activeParagraph: ActiveParagraph | undefined
|
|
102
|
+
let nextParagraphId = 0
|
|
103
|
+
|
|
104
|
+
return lines.map((line, lineIndex) => {
|
|
105
|
+
const context = markdownContext(line, contentStarts[lineIndex] ?? 0)
|
|
106
|
+
const content = blockContent(line, context)
|
|
107
|
+
if (/^[\t ]*\r?$/.test(content)) {
|
|
108
|
+
activeParagraph = undefined
|
|
109
|
+
return context
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (
|
|
113
|
+
activeParagraph !== undefined &&
|
|
114
|
+
context.quoteDepth <= activeParagraph.quoteDepth &&
|
|
115
|
+
!startsNewBlock(content)
|
|
116
|
+
) {
|
|
117
|
+
return { ...context, paragraphId: activeParagraph.id }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (isIndentedCode(content)) {
|
|
121
|
+
activeParagraph = undefined
|
|
122
|
+
return context
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const paragraphId = nextParagraphId++
|
|
126
|
+
activeParagraph = isParagraphBlock(content)
|
|
127
|
+
? { id: paragraphId, quoteDepth: context.quoteDepth }
|
|
128
|
+
: undefined
|
|
129
|
+
return { ...context, paragraphId }
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const isSoftLineBreak = (
|
|
134
|
+
lines: readonly string[],
|
|
135
|
+
contexts: readonly MarkdownContext[],
|
|
136
|
+
previous: WordToken,
|
|
137
|
+
token: WordToken,
|
|
138
|
+
): boolean => {
|
|
139
|
+
if (token.lineIndex !== previous.lineIndex + 1) {
|
|
140
|
+
return false
|
|
141
|
+
}
|
|
142
|
+
const previousLine = lines[previous.lineIndex]
|
|
143
|
+
const nextLine = lines[token.lineIndex]
|
|
144
|
+
if (previousLine === undefined || nextLine === undefined) {
|
|
145
|
+
return false
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const previousContext = contexts[previous.lineIndex]
|
|
149
|
+
const nextContext = contexts[token.lineIndex]
|
|
150
|
+
if (
|
|
151
|
+
previousContext === undefined ||
|
|
152
|
+
nextContext === undefined ||
|
|
153
|
+
previousContext.paragraphId === undefined ||
|
|
154
|
+
previousContext.paragraphId !== nextContext.paragraphId
|
|
155
|
+
) {
|
|
156
|
+
return false
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const lineEnd = previousLine.endsWith("\r") ? previousLine.length - 1 : previousLine.length
|
|
160
|
+
const trailing = previousLine.slice(previous.offset + previous.text.length, lineEnd)
|
|
161
|
+
const leading = nextLine.slice(nextContext.contentStart, token.offset)
|
|
162
|
+
return (trailing === "" || trailing === " ") && /^[\t ]*$/.test(leading)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const hasWords = (
|
|
166
|
+
lines: readonly string[],
|
|
167
|
+
contexts: readonly MarkdownContext[],
|
|
168
|
+
tokens: readonly WordToken[],
|
|
169
|
+
start: number,
|
|
170
|
+
words: readonly string[],
|
|
171
|
+
): boolean =>
|
|
172
|
+
words.every((word, index) => {
|
|
173
|
+
const token = tokens[start + index]
|
|
174
|
+
if (token === undefined || token.lower !== word) {
|
|
175
|
+
return false
|
|
176
|
+
}
|
|
177
|
+
if (index === 0) {
|
|
178
|
+
return true
|
|
179
|
+
}
|
|
180
|
+
const previous = tokens[start + index - 1]
|
|
181
|
+
if (previous === undefined) {
|
|
182
|
+
return false
|
|
183
|
+
}
|
|
184
|
+
if (token.lineIndex === previous.lineIndex) {
|
|
185
|
+
const line = lines[token.lineIndex]
|
|
186
|
+
return (
|
|
187
|
+
line !== undefined &&
|
|
188
|
+
/^\s+$/.test(line.slice(previous.offset + previous.text.length, token.offset))
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
return isSoftLineBreak(lines, contexts, previous, token)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
const hasPartOfSpeech = (
|
|
195
|
+
entry: DictionaryEntry,
|
|
196
|
+
token: WordToken,
|
|
197
|
+
taggedTokens: readonly TaggedToken[] | undefined,
|
|
198
|
+
): boolean => {
|
|
199
|
+
if (entry.partsOfSpeech === undefined) {
|
|
200
|
+
return true
|
|
201
|
+
}
|
|
202
|
+
return (
|
|
203
|
+
taggedTokens?.some(
|
|
204
|
+
(tagged) =>
|
|
205
|
+
tagged.offset === token.offset && entry.partsOfSpeech?.includes(tagged.pos) === true,
|
|
206
|
+
) === true
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const messageFor = (suggestions: readonly string[], found: string): string => {
|
|
211
|
+
const alternatives = suggestions.map((suggestion) => `"${suggestion}"`).join(" or ")
|
|
212
|
+
return `Use ${alternatives}, not "${found}".`
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function dictionaryRule(
|
|
216
|
+
lines: readonly string[],
|
|
217
|
+
dictionary: Dictionary,
|
|
218
|
+
tagger?: Tagger,
|
|
219
|
+
contentStarts: readonly number[] = lines.map(() => 0),
|
|
220
|
+
): Violation[] {
|
|
221
|
+
const forms = compileForms(dictionary)
|
|
222
|
+
const violations: Violation[] = []
|
|
223
|
+
const contexts = markdownContexts(lines, contentStarts)
|
|
224
|
+
const tokens = tokenize(lines)
|
|
225
|
+
const taggedTokensByLine = new Map<number, readonly TaggedToken[]>()
|
|
226
|
+
|
|
227
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
228
|
+
const first = tokens[index]
|
|
229
|
+
if (first === undefined) {
|
|
230
|
+
continue
|
|
231
|
+
}
|
|
232
|
+
const candidates = forms.filter((form) => hasWords(lines, contexts, tokens, index, form.words))
|
|
233
|
+
const match = candidates.find((form) => {
|
|
234
|
+
if (form.entry.partsOfSpeech === undefined) {
|
|
235
|
+
return true
|
|
236
|
+
}
|
|
237
|
+
if (tagger === undefined) {
|
|
238
|
+
return false
|
|
239
|
+
}
|
|
240
|
+
let taggedTokens = taggedTokensByLine.get(first.lineIndex)
|
|
241
|
+
if (taggedTokens === undefined) {
|
|
242
|
+
const line = lines[first.lineIndex]
|
|
243
|
+
if (line === undefined) {
|
|
244
|
+
return false
|
|
245
|
+
}
|
|
246
|
+
taggedTokens = tagger(line)
|
|
247
|
+
taggedTokensByLine.set(first.lineIndex, taggedTokens)
|
|
248
|
+
}
|
|
249
|
+
return hasPartOfSpeech(form.entry, first, taggedTokens)
|
|
250
|
+
})
|
|
251
|
+
if (match === undefined) {
|
|
252
|
+
continue
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const last = tokens[index + match.words.length - 1]
|
|
256
|
+
if (last === undefined) {
|
|
257
|
+
continue
|
|
258
|
+
}
|
|
259
|
+
const found =
|
|
260
|
+
first.lineIndex === last.lineIndex
|
|
261
|
+
? lines[first.lineIndex]?.slice(first.offset, last.offset + last.text.length)
|
|
262
|
+
: tokens
|
|
263
|
+
.slice(index, index + match.words.length)
|
|
264
|
+
.map((token) => token.text)
|
|
265
|
+
.join(" ")
|
|
266
|
+
if (found === undefined) {
|
|
267
|
+
continue
|
|
268
|
+
}
|
|
269
|
+
violations.push({
|
|
270
|
+
ruleId: "dictionary-not-approved-word",
|
|
271
|
+
severity: "hard",
|
|
272
|
+
message: messageFor(match.entry.suggestions, found),
|
|
273
|
+
suggestions: match.entry.suggestions,
|
|
274
|
+
line: first.lineIndex + 1,
|
|
275
|
+
column: first.offset + 1,
|
|
276
|
+
})
|
|
277
|
+
index += match.words.length - 1
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return violations
|
|
281
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { scanLines } from "../scan.ts"
|
|
2
|
+
import { TOKEN_CHARACTER_PATTERN } from "../tokens.ts"
|
|
3
|
+
import type { Violation } from "../types.ts"
|
|
4
|
+
|
|
5
|
+
const HEDGES = [
|
|
6
|
+
"it is important to note",
|
|
7
|
+
"it should be noted",
|
|
8
|
+
"it is worth noting",
|
|
9
|
+
"please note that",
|
|
10
|
+
"as mentioned",
|
|
11
|
+
"as noted above",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
const HEDGE_PATTERN = new RegExp(
|
|
15
|
+
`(?<!${TOKEN_CHARACTER_PATTERN})(?:${HEDGES.map((phrase) => phrase.replace(/ /g, "\\s+")).join("|")})(?!${TOKEN_CHARACTER_PATTERN})`,
|
|
16
|
+
"gi",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
export function hedging(lines: readonly string[]): Violation[] {
|
|
20
|
+
return scanLines(lines, HEDGE_PATTERN).map((match) => ({
|
|
21
|
+
ruleId: "hedging",
|
|
22
|
+
severity: "soft" as const,
|
|
23
|
+
message: `Do not hedge. Delete "${match.found.toLowerCase()}".`,
|
|
24
|
+
line: match.line,
|
|
25
|
+
column: match.column,
|
|
26
|
+
}))
|
|
27
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { TOKEN_RUN_PATTERN } from "../tokens.ts"
|
|
2
|
+
import type { Violation } from "../types.ts"
|
|
3
|
+
|
|
4
|
+
const MARKETING_WORDS = [
|
|
5
|
+
"seamless",
|
|
6
|
+
"seamlessly",
|
|
7
|
+
"robust",
|
|
8
|
+
"powerful",
|
|
9
|
+
"cutting-edge",
|
|
10
|
+
"effortless",
|
|
11
|
+
"effortlessly",
|
|
12
|
+
"world-class",
|
|
13
|
+
"next-generation",
|
|
14
|
+
"revolutionary",
|
|
15
|
+
"blazing",
|
|
16
|
+
"lightning-fast",
|
|
17
|
+
"elegant",
|
|
18
|
+
"delightful",
|
|
19
|
+
"turnkey",
|
|
20
|
+
"best-in-class",
|
|
21
|
+
"state-of-the-art",
|
|
22
|
+
"game-changing",
|
|
23
|
+
"battle-tested",
|
|
24
|
+
"enterprise-grade",
|
|
25
|
+
"supercharge",
|
|
26
|
+
"unleash",
|
|
27
|
+
"empower",
|
|
28
|
+
"empowers",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
const MARKETING_SET = new Set(MARKETING_WORDS)
|
|
32
|
+
|
|
33
|
+
interface MarketingMatch {
|
|
34
|
+
readonly found: string
|
|
35
|
+
readonly offset: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function findMarketingLanguage(token: string): MarketingMatch | undefined {
|
|
39
|
+
const normalized = token.toLowerCase()
|
|
40
|
+
if (MARKETING_SET.has(normalized)) {
|
|
41
|
+
return { found: normalized, offset: 0 }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let offset = 0
|
|
45
|
+
for (const part of normalized.split("-")) {
|
|
46
|
+
if (MARKETING_SET.has(part)) {
|
|
47
|
+
return { found: part, offset }
|
|
48
|
+
}
|
|
49
|
+
offset += part.length + 1
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function marketing(lines: readonly string[]): Violation[] {
|
|
54
|
+
return lines.flatMap((line, lineIndex) =>
|
|
55
|
+
Array.from(line.matchAll(TOKEN_RUN_PATTERN)).flatMap((tokenMatch) => {
|
|
56
|
+
const match = findMarketingLanguage(tokenMatch[0])
|
|
57
|
+
if (!match) {
|
|
58
|
+
return []
|
|
59
|
+
}
|
|
60
|
+
return [
|
|
61
|
+
{
|
|
62
|
+
ruleId: "marketing",
|
|
63
|
+
severity: "soft" as const,
|
|
64
|
+
message: `Do not use marketing language. Delete "${match.found}".`,
|
|
65
|
+
line: lineIndex + 1,
|
|
66
|
+
column: tokenMatch.index + match.offset + 1,
|
|
67
|
+
},
|
|
68
|
+
]
|
|
69
|
+
}),
|
|
70
|
+
)
|
|
71
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Paragraph } from "../paragraphs.ts"
|
|
2
|
+
import { segmentSentences } from "../sentences.ts"
|
|
3
|
+
import type { Violation } from "../types.ts"
|
|
4
|
+
|
|
5
|
+
const MAX_SENTENCES = 6
|
|
6
|
+
|
|
7
|
+
export function paragraphLength(paragraphs: readonly Paragraph[]): Violation[] {
|
|
8
|
+
return paragraphs.flatMap((paragraph) => {
|
|
9
|
+
const count = segmentSentences(paragraph.lines).length
|
|
10
|
+
if (count <= MAX_SENTENCES) {
|
|
11
|
+
return []
|
|
12
|
+
}
|
|
13
|
+
return [
|
|
14
|
+
{
|
|
15
|
+
ruleId: "paragraph-length",
|
|
16
|
+
severity: "hard" as const,
|
|
17
|
+
message: `Paragraph has ${count} sentences; the maximum is ${MAX_SENTENCES}.`,
|
|
18
|
+
line: paragraph.line,
|
|
19
|
+
column: paragraph.column,
|
|
20
|
+
},
|
|
21
|
+
]
|
|
22
|
+
})
|
|
23
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { scanLines } from "../scan.ts"
|
|
2
|
+
import { TOKEN_CHARACTER_PATTERN } from "../tokens.ts"
|
|
3
|
+
import type { Violation } from "../types.ts"
|
|
4
|
+
|
|
5
|
+
interface PhrasalVerbEntry {
|
|
6
|
+
readonly forms: readonly string[]
|
|
7
|
+
readonly suggestion: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// List and suggestions follow the pi-ste reference implementation, extended
|
|
11
|
+
// with conjugated forms and "carry out" from the HUF-132 spec.
|
|
12
|
+
const PHRASAL_VERBS: readonly PhrasalVerbEntry[] = [
|
|
13
|
+
{ forms: ["carry out", "carries out", "carried out", "carrying out"], suggestion: "do" },
|
|
14
|
+
{ forms: ["spin up", "spins up", "spun up", "spinning up"], suggestion: "start" },
|
|
15
|
+
{ forms: ["spin down", "spins down", "spun down", "spinning down"], suggestion: "stop" },
|
|
16
|
+
{
|
|
17
|
+
forms: ["tear down", "tears down", "tore down", "torn down", "tearing down"],
|
|
18
|
+
suggestion: "remove",
|
|
19
|
+
},
|
|
20
|
+
{ forms: ["reach out", "reaches out", "reached out", "reaching out"], suggestion: "ask" },
|
|
21
|
+
{
|
|
22
|
+
forms: ["dive into", "dives into", "dived into", "dove into", "diving into"],
|
|
23
|
+
suggestion: "examine",
|
|
24
|
+
},
|
|
25
|
+
{ forms: ["kick off", "kicks off", "kicked off", "kicking off"], suggestion: "start" },
|
|
26
|
+
{ forms: ["roll out", "rolls out", "rolled out", "rolling out"], suggestion: "release" },
|
|
27
|
+
{ forms: ["ramp up", "ramps up", "ramped up", "ramping up"], suggestion: "increase" },
|
|
28
|
+
{
|
|
29
|
+
forms: ["circle back", "circles back", "circled back", "circling back"],
|
|
30
|
+
suggestion: "return",
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
forms: ["drill down", "drills down", "drilled down", "drilling down"],
|
|
34
|
+
suggestion: "examine",
|
|
35
|
+
},
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
const patterns = PHRASAL_VERBS.map((entry) => ({
|
|
39
|
+
suggestion: entry.suggestion,
|
|
40
|
+
pattern: new RegExp(
|
|
41
|
+
`(?<!${TOKEN_CHARACTER_PATTERN})(?:${entry.forms.map((form) => form.replace(/ /g, "\\s+")).join("|")})(?!${TOKEN_CHARACTER_PATTERN})`,
|
|
42
|
+
"gi",
|
|
43
|
+
),
|
|
44
|
+
}))
|
|
45
|
+
|
|
46
|
+
export function phrasalVerb(lines: readonly string[]): Violation[] {
|
|
47
|
+
return patterns.flatMap(({ pattern, suggestion }) =>
|
|
48
|
+
scanLines(lines, pattern).map((match) => ({
|
|
49
|
+
ruleId: "phrasal-verb",
|
|
50
|
+
severity: "hard" as const,
|
|
51
|
+
message: `Do not use a phrasal verb. Use "${suggestion}", not "${match.found.toLowerCase()}".`,
|
|
52
|
+
line: match.line,
|
|
53
|
+
column: match.column,
|
|
54
|
+
suggestion,
|
|
55
|
+
})),
|
|
56
|
+
)
|
|
57
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const ruleIds = [
|
|
2
|
+
"contraction",
|
|
3
|
+
"dictionary-not-approved-word",
|
|
4
|
+
"hedging",
|
|
5
|
+
"marketing",
|
|
6
|
+
"paragraph-length",
|
|
7
|
+
"phrasal-verb",
|
|
8
|
+
"semicolon",
|
|
9
|
+
"sentence-length",
|
|
10
|
+
"verb-progressive",
|
|
11
|
+
"verb-passive",
|
|
12
|
+
"verb-perfect",
|
|
13
|
+
] as const
|
|
14
|
+
|
|
15
|
+
export type RuleId = (typeof ruleIds)[number]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { scanLines } from "../scan.ts"
|
|
2
|
+
import type { Violation } from "../types.ts"
|
|
3
|
+
|
|
4
|
+
const SEMICOLON = /;/g
|
|
5
|
+
|
|
6
|
+
export function semicolon(lines: readonly string[]): Violation[] {
|
|
7
|
+
return scanLines(lines, SEMICOLON).map((match) => ({
|
|
8
|
+
ruleId: "semicolon",
|
|
9
|
+
severity: "hard" as const,
|
|
10
|
+
message: "Do not use a semicolon. Write two sentences.",
|
|
11
|
+
line: match.line,
|
|
12
|
+
column: match.column,
|
|
13
|
+
}))
|
|
14
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Sentence } from "../sentences.ts"
|
|
2
|
+
import type { Violation } from "../types.ts"
|
|
3
|
+
|
|
4
|
+
export function sentenceLength(sentences: readonly Sentence[], maxWords: number): Violation[] {
|
|
5
|
+
return sentences.flatMap((sentence) => {
|
|
6
|
+
const count = countWords(sentence.text)
|
|
7
|
+
if (count <= maxWords) {
|
|
8
|
+
return []
|
|
9
|
+
}
|
|
10
|
+
return [
|
|
11
|
+
{
|
|
12
|
+
ruleId: "sentence-length",
|
|
13
|
+
severity: "hard" as const,
|
|
14
|
+
message: `Sentence has ${count} words; the maximum is ${maxWords}.`,
|
|
15
|
+
line: sentence.line,
|
|
16
|
+
column: sentence.column,
|
|
17
|
+
},
|
|
18
|
+
]
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function countWords(text: string): number {
|
|
23
|
+
return text.split(/\s+/).filter((word) => word !== "").length
|
|
24
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { TaggedToken, Tagger } from "../tagger.ts"
|
|
2
|
+
import type { Violation } from "../types.ts"
|
|
3
|
+
|
|
4
|
+
const BE_FORMS = new Set(["am", "is", "are", "was", "were", "be", "been", "being"])
|
|
5
|
+
|
|
6
|
+
const isBeForm = (token: TaggedToken) => BE_FORMS.has(token.text.toLowerCase())
|
|
7
|
+
|
|
8
|
+
const isPerfectAuxiliary = (token: TaggedToken) => token.pos === "AUX" && token.lemma === "have"
|
|
9
|
+
|
|
10
|
+
// Adverbs and "not" may sit between the auxiliary and its verb
|
|
11
|
+
// ("was quickly closed", "were not shown") without breaking the construct.
|
|
12
|
+
const isSkippable = (token: TaggedToken) =>
|
|
13
|
+
token.pos === "ADV" || token.text.toLowerCase() === "not"
|
|
14
|
+
|
|
15
|
+
const isProgressiveVerb = (token: TaggedToken) =>
|
|
16
|
+
token.pos === "VERB" && token.text.toLowerCase().endsWith("ing")
|
|
17
|
+
|
|
18
|
+
// After a be/have auxiliary, any non-"ing" verb is a past participle in
|
|
19
|
+
// practice, which covers irregular forms (broken, sent, written) without a list.
|
|
20
|
+
const isPastParticiple = (token: TaggedToken) =>
|
|
21
|
+
token.pos === "VERB" && !token.text.toLowerCase().endsWith("ing")
|
|
22
|
+
|
|
23
|
+
function nextContentToken(tokens: readonly TaggedToken[], start: number): TaggedToken | undefined {
|
|
24
|
+
for (let i = start; i < tokens.length; i++) {
|
|
25
|
+
const token = tokens[i]
|
|
26
|
+
if (token !== undefined && !isSkippable(token)) {
|
|
27
|
+
return token
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return undefined
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function verbForm(lines: readonly string[], tag: Tagger): Violation[] {
|
|
34
|
+
const violations: Violation[] = []
|
|
35
|
+
|
|
36
|
+
lines.forEach((line, index) => {
|
|
37
|
+
if (line.trim() === "") {
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
const tokens = tag(line)
|
|
41
|
+
|
|
42
|
+
tokens.forEach((token, i) => {
|
|
43
|
+
const head = nextContentToken(tokens, i + 1)
|
|
44
|
+
if (head === undefined) {
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
const found = line.slice(token.offset, head.offset + head.text.length)
|
|
48
|
+
const position = { line: index + 1, column: token.offset + 1 }
|
|
49
|
+
|
|
50
|
+
if (isBeForm(token) && isProgressiveVerb(head)) {
|
|
51
|
+
violations.push({
|
|
52
|
+
ruleId: "verb-progressive",
|
|
53
|
+
severity: "hard",
|
|
54
|
+
message: `Use a simple tense. Do not use the progressive. Found: "${found}".`,
|
|
55
|
+
...position,
|
|
56
|
+
})
|
|
57
|
+
} else if (isBeForm(token) && isPastParticiple(head)) {
|
|
58
|
+
violations.push({
|
|
59
|
+
ruleId: "verb-passive",
|
|
60
|
+
severity: "soft",
|
|
61
|
+
message: `Use the active voice, unless the actor is unknown. Found: "${found}".`,
|
|
62
|
+
...position,
|
|
63
|
+
})
|
|
64
|
+
} else if (isPerfectAuxiliary(token) && isPastParticiple(head)) {
|
|
65
|
+
violations.push({
|
|
66
|
+
ruleId: "verb-perfect",
|
|
67
|
+
severity: "hard",
|
|
68
|
+
message: `Use the simple past. Do not use the perfect tense. Found: "${found}".`,
|
|
69
|
+
...position,
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
return violations
|
|
76
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface LineMatch {
|
|
2
|
+
readonly found: string
|
|
3
|
+
readonly line: number
|
|
4
|
+
readonly column: number
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function scanLines(lines: readonly string[], pattern: RegExp): LineMatch[] {
|
|
8
|
+
return lines.flatMap((line, index) =>
|
|
9
|
+
Array.from(line.matchAll(pattern), (match) => ({
|
|
10
|
+
found: match[0],
|
|
11
|
+
line: index + 1,
|
|
12
|
+
column: match.index + 1,
|
|
13
|
+
})),
|
|
14
|
+
)
|
|
15
|
+
}
|