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