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.
Files changed (47) hide show
  1. package/.claude-plugin/marketplace.json +23 -0
  2. package/.claude-plugin/plugin.json +12 -0
  3. package/LICENSE +21 -0
  4. package/README.md +435 -0
  5. package/THIRD_PARTY_NOTICES.md +13 -0
  6. package/commands/ste.md +13 -0
  7. package/hooks/hooks.json +58 -0
  8. package/package.json +64 -0
  9. package/src/adapter/commit-message.ts +472 -0
  10. package/src/adapter/feedback.ts +40 -0
  11. package/src/adapter/rule-summary.ts +79 -0
  12. package/src/cli/hook.ts +681 -0
  13. package/src/cli/main.ts +201 -0
  14. package/src/cli/session-command.ts +78 -0
  15. package/src/cli/session-state.ts +214 -0
  16. package/src/config/load.ts +85 -0
  17. package/src/config/merge.ts +20 -0
  18. package/src/config/schema.ts +69 -0
  19. package/src/dictionary/README.md +18 -0
  20. package/src/dictionary/data/pi-ste.json +200 -0
  21. package/src/dictionary/form.ts +6 -0
  22. package/src/dictionary/load.ts +54 -0
  23. package/src/dictionary/schema.ts +28 -0
  24. package/src/engine/comments.ts +386 -0
  25. package/src/engine/diff.ts +328 -0
  26. package/src/engine/identifiers.ts +20 -0
  27. package/src/engine/kinds.ts +43 -0
  28. package/src/engine/lint.ts +530 -0
  29. package/src/engine/markdown.ts +338 -0
  30. package/src/engine/paragraphs.ts +105 -0
  31. package/src/engine/rules/contraction.ts +19 -0
  32. package/src/engine/rules/dictionary.ts +281 -0
  33. package/src/engine/rules/hedging.ts +27 -0
  34. package/src/engine/rules/marketing.ts +71 -0
  35. package/src/engine/rules/paragraph-length.ts +23 -0
  36. package/src/engine/rules/phrasal-verb.ts +57 -0
  37. package/src/engine/rules/registry.ts +15 -0
  38. package/src/engine/rules/semicolon.ts +14 -0
  39. package/src/engine/rules/sentence-length.ts +24 -0
  40. package/src/engine/rules/verb-form.ts +76 -0
  41. package/src/engine/scan.ts +15 -0
  42. package/src/engine/sentences.ts +285 -0
  43. package/src/engine/tagger.ts +8 -0
  44. package/src/engine/tokens.ts +2 -0
  45. package/src/engine/types.ts +45 -0
  46. package/src/extension/index.ts +755 -0
  47. 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
+ }