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,530 @@
1
+ import type { Dictionary } from "../dictionary/schema.ts"
2
+ import { type ProseBreak, extractHashComments, extractSlashComments } from "./comments.ts"
3
+ import { type RetainedRange, changedText } from "./diff.ts"
4
+ import { blankIdentifiers } from "./identifiers.ts"
5
+ import { blankMarkdownCodeWithStructure } from "./markdown.ts"
6
+ import { type Paragraph, segmentParagraphs } from "./paragraphs.ts"
7
+ import { contraction } from "./rules/contraction.ts"
8
+ import { dictionaryRule } from "./rules/dictionary.ts"
9
+ import { hedging } from "./rules/hedging.ts"
10
+ import { marketing } from "./rules/marketing.ts"
11
+ import { paragraphLength } from "./rules/paragraph-length.ts"
12
+ import { phrasalVerb } from "./rules/phrasal-verb.ts"
13
+ import { semicolon } from "./rules/semicolon.ts"
14
+ import { sentenceLength } from "./rules/sentence-length.ts"
15
+ import { verbForm } from "./rules/verb-form.ts"
16
+ import { type Sentence, segmentSentences } from "./sentences.ts"
17
+ import type { Tagger } from "./tagger.ts"
18
+ import type { LintKind, LintOptions, LintReport, Violation } from "./types.ts"
19
+
20
+ export const DEFAULT_MAX_SENTENCE_WORDS = 25
21
+
22
+ interface ResolvedOptions {
23
+ readonly maxSentenceWords: number
24
+ readonly dictionary?: Dictionary
25
+ readonly tagger?: Tagger
26
+ }
27
+
28
+ interface ExtractedProse {
29
+ readonly lines: readonly string[]
30
+ readonly contentStarts: readonly number[]
31
+ readonly proseBreaks: readonly ProseBreak[]
32
+ }
33
+
34
+ interface ProseRun extends ExtractedProse {
35
+ readonly lineOffset: number
36
+ readonly firstColumnOffset: number
37
+ readonly sourceOffset: number
38
+ }
39
+
40
+ interface PreparedProse {
41
+ readonly lines: readonly string[]
42
+ readonly structuralLines: readonly string[]
43
+ readonly mechanicalLines: readonly string[]
44
+ readonly structuralBlanks: readonly boolean[]
45
+ }
46
+
47
+ interface ViolationScope {
48
+ readonly kind: "sentence" | "paragraph"
49
+ readonly identity: string
50
+ readonly startOffset: number
51
+ readonly endOffset: number
52
+ }
53
+
54
+ interface ScopedViolation {
55
+ readonly violation: Violation
56
+ readonly scope: ViolationScope
57
+ readonly sentenceIdentity?: string
58
+ readonly occurrenceOffset?: number
59
+ }
60
+
61
+ interface SentenceScopeIndex {
62
+ readonly scopes: readonly ViolationScope[]
63
+ readonly firstScopeByLine: Int32Array
64
+ }
65
+
66
+ interface FindingSearchIndex {
67
+ readonly candidates: ScopedViolation[]
68
+ cursor: number
69
+ }
70
+
71
+ const wholeText = (text: string): ExtractedProse => {
72
+ const lines = text.split("\n")
73
+ return { lines, contentStarts: lines.map(() => 0), proseBreaks: [] }
74
+ }
75
+
76
+ const splitProseRuns = (extracted: ExtractedProse): readonly ProseRun[] => {
77
+ const boundaries: readonly (ProseBreak | undefined)[] = [
78
+ undefined,
79
+ ...extracted.proseBreaks,
80
+ undefined,
81
+ ]
82
+ const lineOffsets: number[] = []
83
+ let nextLineOffset = 0
84
+ for (const line of extracted.lines) {
85
+ lineOffsets.push(nextLineOffset)
86
+ nextLineOffset += line.length + 1
87
+ }
88
+
89
+ return boundaries.slice(0, -1).map((start, runIndex) => {
90
+ const end = boundaries[runIndex + 1]
91
+ const firstLine = start?.line ?? 0
92
+ const lastLine = end?.line ?? extracted.lines.length - 1
93
+ const firstColumnOffset = Math.min(start?.column ?? 0, extracted.lines[firstLine]?.length ?? 0)
94
+ const sourceLines = extracted.lines.slice(firstLine, lastLine + 1)
95
+ const lines = sourceLines.map((line, index) => {
96
+ const lineIndex = firstLine + index
97
+ const from = lineIndex === firstLine ? firstColumnOffset : 0
98
+ const to = end?.line === lineIndex ? Math.min(end.column, line.length) : line.length
99
+ return line.slice(from, to)
100
+ })
101
+ const contentStarts = lines.map((line, index) => {
102
+ const lineIndex = firstLine + index
103
+ const from = lineIndex === firstLine ? firstColumnOffset : 0
104
+ const contentStart = extracted.contentStarts[lineIndex] ?? from
105
+ return Math.min(Math.max(contentStart - from, 0), line.length)
106
+ })
107
+ return {
108
+ lines,
109
+ contentStarts,
110
+ proseBreaks: [],
111
+ lineOffset: firstLine,
112
+ firstColumnOffset,
113
+ sourceOffset: (lineOffsets[firstLine] ?? 0) + firstColumnOffset,
114
+ }
115
+ })
116
+ }
117
+
118
+ const extract = (kind: LintKind, text: string, options: LintOptions): ExtractedProse => {
119
+ if (kind === "slash-source") return extractSlashComments(text)
120
+ if (kind === "hash-source") return extractHashComments(text, options.sourceDialect)
121
+ return wholeText(text)
122
+ }
123
+
124
+ const prepareProse = (extracted: ProseRun): PreparedProse => {
125
+ const markdown = blankMarkdownCodeWithStructure(extracted.lines, extracted.contentStarts)
126
+ return {
127
+ lines: blankIdentifiers(markdown.lines),
128
+ structuralLines: blankIdentifiers(markdown.structuralLines),
129
+ mechanicalLines: blankIdentifiers(
130
+ extracted.lines.map((line, index) =>
131
+ markdown.structuralBlanks[index] ? " ".repeat(line.length) : line,
132
+ ),
133
+ ),
134
+ structuralBlanks: markdown.structuralBlanks,
135
+ }
136
+ }
137
+
138
+ const normalizeIdentity = (text: string): string => text.replace(/\s+/gu, " ").trim()
139
+
140
+ const lineOffsets = (lines: readonly string[]): readonly number[] => {
141
+ const offsets: number[] = []
142
+ let nextOffset = 0
143
+ for (const line of lines) {
144
+ offsets.push(nextOffset)
145
+ nextOffset += line.length + 1
146
+ }
147
+ return offsets
148
+ }
149
+
150
+ const sentenceScope = (sentence: Sentence, sourceOffset: number): ViolationScope => ({
151
+ kind: "sentence",
152
+ identity: normalizeIdentity(sentence.text),
153
+ startOffset: sourceOffset + sentence.startOffset,
154
+ endOffset: sourceOffset + sentence.endOffset,
155
+ })
156
+
157
+ function paragraphScope(
158
+ paragraph: Paragraph,
159
+ lines: readonly string[],
160
+ offsets: readonly number[],
161
+ sourceOffset: number,
162
+ ): ViolationScope {
163
+ const endLine = paragraph.line + paragraph.lines.length - 1
164
+ const startOffset = offsets[paragraph.line - 1] ?? 0
165
+ return {
166
+ kind: "paragraph",
167
+ identity: normalizeIdentity(paragraph.lines.join("\n")),
168
+ startOffset: sourceOffset + startOffset,
169
+ endOffset:
170
+ sourceOffset + (offsets[endLine - 1] ?? startOffset) + (lines[endLine - 1]?.length ?? 0),
171
+ }
172
+ }
173
+
174
+ function indexSentenceScopes(
175
+ sentences: readonly Sentence[],
176
+ lineCount: number,
177
+ sourceOffset: number,
178
+ ): SentenceScopeIndex {
179
+ const scopes = sentences.map((sentence) => sentenceScope(sentence, sourceOffset))
180
+ const firstScopeByLine = new Int32Array(lineCount).fill(-1)
181
+
182
+ for (let scopeIndex = 0; scopeIndex < sentences.length; scopeIndex++) {
183
+ const sentence = sentences[scopeIndex]
184
+ if (sentence === undefined) continue
185
+ for (let line = sentence.line - 1; line < sentence.endLine; line++) {
186
+ if (firstScopeByLine[line] === -1) firstScopeByLine[line] = scopeIndex
187
+ }
188
+ }
189
+
190
+ return { scopes, firstScopeByLine }
191
+ }
192
+
193
+ function scopeForViolation(
194
+ violation: Violation,
195
+ sentenceIndex: SentenceScopeIndex,
196
+ lines: readonly string[],
197
+ offsets: readonly number[],
198
+ sourceOffset: number,
199
+ ): ViolationScope {
200
+ const localOffset = (offsets[violation.line - 1] ?? 0) + violation.column - 1
201
+ const offset = sourceOffset + localOffset
202
+ let low = 0
203
+ let high = sentenceIndex.scopes.length
204
+ while (low < high) {
205
+ const middle = Math.floor((low + high) / 2)
206
+ const scope = sentenceIndex.scopes[middle]
207
+ if (scope !== undefined && scope.startOffset <= offset) {
208
+ low = middle + 1
209
+ } else {
210
+ high = middle
211
+ }
212
+ }
213
+
214
+ const containingScope = sentenceIndex.scopes[low - 1]
215
+ if (
216
+ containingScope !== undefined &&
217
+ containingScope.startOffset <= offset &&
218
+ offset < containingScope.endOffset
219
+ ) {
220
+ return containingScope
221
+ }
222
+
223
+ const lineScopeIndex = sentenceIndex.firstScopeByLine[violation.line - 1] ?? -1
224
+ const lineScope = sentenceIndex.scopes[lineScopeIndex]
225
+ if (lineScope !== undefined) return lineScope
226
+
227
+ const line = lines[violation.line - 1] ?? ""
228
+ const startOffset = offsets[violation.line - 1] ?? 0
229
+ return {
230
+ kind: "sentence",
231
+ identity: normalizeIdentity(line),
232
+ startOffset: sourceOffset + startOffset,
233
+ endOffset: sourceOffset + startOffset + line.length,
234
+ }
235
+ }
236
+
237
+ const lintProse = (
238
+ prepared: PreparedProse,
239
+ contentStarts: readonly number[],
240
+ sourceOffset: number,
241
+ options: ResolvedOptions,
242
+ ): ScopedViolation[] => {
243
+ const sourceText = prepared.lines.join("\n")
244
+ const sentences = segmentSentences(prepared.lines, sourceText, prepared.structuralBlanks)
245
+ const paragraphs = segmentParagraphs(
246
+ prepared.structuralLines.map((line, index) => line.slice(contentStarts[index] ?? 0)),
247
+ contentStarts.map((contentStart) => contentStart + 1),
248
+ )
249
+ const offsets = lineOffsets(prepared.structuralLines)
250
+ const sentenceIndex = indexSentenceScopes(sentences, prepared.lines.length, sourceOffset)
251
+ const sentenceFindings = (violations: readonly Violation[]): ScopedViolation[] =>
252
+ violations.map((violation) => {
253
+ const scope = scopeForViolation(
254
+ violation,
255
+ sentenceIndex,
256
+ prepared.structuralLines,
257
+ offsets,
258
+ sourceOffset,
259
+ )
260
+ return {
261
+ violation,
262
+ scope,
263
+ sentenceIdentity: scope.identity,
264
+ occurrenceOffset: sourceOffset + (offsets[violation.line - 1] ?? 0) + violation.column - 1,
265
+ }
266
+ })
267
+
268
+ return [
269
+ ...sentences.flatMap((sentence) =>
270
+ sentenceLength([sentence], options.maxSentenceWords).map((violation) => ({
271
+ violation,
272
+ scope: sentenceScope(sentence, sourceOffset),
273
+ })),
274
+ ),
275
+ ...paragraphs.flatMap((paragraph) =>
276
+ paragraphLength([paragraph]).map((violation) => ({
277
+ violation,
278
+ scope: paragraphScope(paragraph, prepared.structuralLines, offsets, sourceOffset),
279
+ })),
280
+ ),
281
+ ...sentenceFindings(contraction(prepared.lines)),
282
+ ...sentenceFindings(semicolon(prepared.mechanicalLines)),
283
+ ...sentenceFindings(phrasalVerb(prepared.lines)),
284
+ ...sentenceFindings(hedging(prepared.lines)),
285
+ ...sentenceFindings(marketing(prepared.lines)),
286
+ ...(options.dictionary === undefined
287
+ ? []
288
+ : sentenceFindings(
289
+ dictionaryRule(
290
+ prepared.structuralLines,
291
+ options.dictionary,
292
+ options.tagger,
293
+ contentStarts,
294
+ ),
295
+ )),
296
+ ...(options.tagger === undefined
297
+ ? []
298
+ : sentenceFindings(verbForm(prepared.lines, options.tagger))),
299
+ ]
300
+ }
301
+
302
+ const lintExtracted = (extracted: ProseRun, options: ResolvedOptions): ScopedViolation[] =>
303
+ lintProse(prepareProse(extracted), extracted.contentStarts, extracted.sourceOffset, options)
304
+
305
+ function configuredFinding(
306
+ finding: ScopedViolation,
307
+ options: LintOptions,
308
+ ): ScopedViolation | undefined {
309
+ const setting = options.rules?.[finding.violation.ruleId]
310
+ if (setting === "off") return undefined
311
+ if (setting === undefined) return finding
312
+ return { ...finding, violation: { ...finding.violation, severity: setting } }
313
+ }
314
+
315
+ function evaluate(kind: LintKind, text: string, options: LintOptions): ScopedViolation[] {
316
+ const resolved: ResolvedOptions = {
317
+ maxSentenceWords: options.maxSentenceWords ?? DEFAULT_MAX_SENTENCE_WORDS,
318
+ dictionary: options.dictionary,
319
+ tagger: options.tagger,
320
+ }
321
+ return splitProseRuns(extract(kind, text, options))
322
+ .flatMap((run) =>
323
+ lintExtracted(run, resolved).map((finding) => ({
324
+ ...finding,
325
+ violation: {
326
+ ...finding.violation,
327
+ line: finding.violation.line + run.lineOffset,
328
+ column:
329
+ finding.violation.column + (finding.violation.line === 1 ? run.firstColumnOffset : 0),
330
+ },
331
+ })),
332
+ )
333
+ .flatMap((finding) => {
334
+ const configured = configuredFinding(finding, options)
335
+ return configured === undefined ? [] : [configured]
336
+ })
337
+ .sort(
338
+ (left, right) =>
339
+ left.violation.line - right.violation.line ||
340
+ left.violation.column - right.violation.column,
341
+ )
342
+ }
343
+
344
+ const findingKey = (finding: ScopedViolation): string =>
345
+ `${finding.violation.ruleId}\u0000${finding.scope.kind}\u0000${finding.sentenceIdentity ?? ""}`
346
+
347
+ type RetainedSide = "current" | "previous"
348
+
349
+ function firstRetainedIndex(
350
+ retained: readonly RetainedRange[],
351
+ offset: number,
352
+ side: RetainedSide,
353
+ ): number {
354
+ let low = 0
355
+ let high = retained.length
356
+ while (low < high) {
357
+ const middle = Math.floor((low + high) / 2)
358
+ const range = retained[middle]
359
+ const start = side === "current" ? range?.currentStart : range?.previousStart
360
+ if (range !== undefined && (start ?? 0) + range.length <= offset) {
361
+ low = middle + 1
362
+ } else {
363
+ high = middle
364
+ }
365
+ }
366
+ return low
367
+ }
368
+
369
+ function retainedScopeMapsInto(
370
+ source: ViolationScope,
371
+ target: ViolationScope,
372
+ retained: readonly RetainedRange[],
373
+ sourceSide: RetainedSide,
374
+ ): boolean {
375
+ let mapped = false
376
+ for (
377
+ let index = firstRetainedIndex(retained, source.startOffset, sourceSide);
378
+ index < retained.length;
379
+ index++
380
+ ) {
381
+ const range = retained[index]
382
+ if (range === undefined) break
383
+ const sourceStart = sourceSide === "current" ? range.currentStart : range.previousStart
384
+ const targetStart = sourceSide === "current" ? range.previousStart : range.currentStart
385
+ if (sourceStart >= source.endOffset) break
386
+ const overlapStart = Math.max(source.startOffset, sourceStart)
387
+ const overlapEnd = Math.min(source.endOffset, sourceStart + range.length)
388
+ if (overlapStart >= overlapEnd) continue
389
+ mapped = true
390
+ const mappedStart = targetStart + overlapStart - sourceStart
391
+ const mappedEnd = targetStart + overlapEnd - sourceStart
392
+ if (mappedStart < target.startOffset || mappedEnd > target.endOffset) return false
393
+ }
394
+ return mapped
395
+ }
396
+
397
+ function scopesCorrespond(
398
+ previous: ViolationScope,
399
+ current: ViolationScope,
400
+ retained: readonly RetainedRange[],
401
+ ): boolean {
402
+ return (
403
+ retainedScopeMapsInto(current, previous, retained, "current") &&
404
+ retainedScopeMapsInto(previous, current, retained, "previous")
405
+ )
406
+ }
407
+
408
+ function firstMappedPreviousOffset(
409
+ scope: ViolationScope,
410
+ retained: readonly RetainedRange[],
411
+ ): number | undefined {
412
+ for (
413
+ let index = firstRetainedIndex(retained, scope.startOffset, "current");
414
+ index < retained.length;
415
+ index++
416
+ ) {
417
+ const range = retained[index]
418
+ if (range === undefined || range.currentStart >= scope.endOffset) break
419
+ const overlapStart = Math.max(scope.startOffset, range.currentStart)
420
+ const overlapEnd = Math.min(scope.endOffset, range.currentStart + range.length)
421
+ if (overlapStart < overlapEnd) {
422
+ return range.previousStart + overlapStart - range.currentStart
423
+ }
424
+ }
425
+ return undefined
426
+ }
427
+
428
+ function mappedPreviousOffset(
429
+ offset: number,
430
+ retained: readonly RetainedRange[],
431
+ ): number | undefined {
432
+ const range = retained[firstRetainedIndex(retained, offset, "current")]
433
+ if (
434
+ range === undefined ||
435
+ offset < range.currentStart ||
436
+ offset >= range.currentStart + range.length
437
+ ) {
438
+ return undefined
439
+ }
440
+ return range.previousStart + offset - range.currentStart
441
+ }
442
+
443
+ function newFindings(
444
+ previous: readonly ScopedViolation[],
445
+ current: readonly ScopedViolation[],
446
+ retained: readonly RetainedRange[],
447
+ ): ScopedViolation[] {
448
+ const previousByKey = new Map<string, FindingSearchIndex>()
449
+ for (const finding of previous) {
450
+ const key = findingKey(finding)
451
+ const index = previousByKey.get(key)
452
+ if (index === undefined) {
453
+ previousByKey.set(key, { candidates: [finding], cursor: 0 })
454
+ } else {
455
+ index.candidates.push(finding)
456
+ }
457
+ }
458
+
459
+ const findings: ScopedViolation[] = []
460
+ for (const finding of current) {
461
+ const index = previousByKey.get(findingKey(finding))
462
+ const mappedScopeOffset = firstMappedPreviousOffset(finding.scope, retained)
463
+ const mappedOccurrenceOffset =
464
+ finding.occurrenceOffset === undefined
465
+ ? undefined
466
+ : mappedPreviousOffset(finding.occurrenceOffset, retained)
467
+ if (
468
+ index === undefined ||
469
+ mappedScopeOffset === undefined ||
470
+ (finding.occurrenceOffset !== undefined && mappedOccurrenceOffset === undefined)
471
+ ) {
472
+ findings.push(finding)
473
+ continue
474
+ }
475
+
476
+ let matched = false
477
+ while (index.cursor < index.candidates.length) {
478
+ const candidate = index.candidates[index.cursor]
479
+ if (candidate === undefined) break
480
+ if (candidate.scope.endOffset <= mappedScopeOffset) {
481
+ index.cursor++
482
+ continue
483
+ }
484
+ if (candidate.scope.startOffset > mappedScopeOffset) break
485
+ if (!scopesCorrespond(candidate.scope, finding.scope, retained)) {
486
+ index.cursor++
487
+ continue
488
+ }
489
+ if (finding.occurrenceOffset === undefined) {
490
+ index.cursor++
491
+ matched = true
492
+ break
493
+ }
494
+ if (
495
+ candidate.occurrenceOffset !== undefined &&
496
+ candidate.occurrenceOffset < (mappedOccurrenceOffset ?? 0)
497
+ ) {
498
+ index.cursor++
499
+ continue
500
+ }
501
+ if (candidate.occurrenceOffset !== mappedOccurrenceOffset) break
502
+ index.cursor++
503
+ matched = true
504
+ break
505
+ }
506
+ if (!matched) findings.push(finding)
507
+ }
508
+
509
+ return findings
510
+ }
511
+
512
+ export function lint(kind: LintKind, text: string, options: LintOptions = {}): LintReport {
513
+ const current = evaluate(kind, text, options)
514
+ const findings =
515
+ options.previousText === undefined
516
+ ? current
517
+ : newFindings(
518
+ evaluate(kind, options.previousText, options),
519
+ current,
520
+ changedText(options.previousText, text).retained,
521
+ )
522
+ const violations = findings.map((finding) => finding.violation)
523
+ return {
524
+ violations,
525
+ summary: {
526
+ total: violations.length,
527
+ hard: violations.filter((violation) => violation.severity === "hard").length,
528
+ },
529
+ }
530
+ }