agent-simple-english 0.7.0 → 0.8.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 +2 -2
- package/.claude-plugin/plugin.json +2 -2
- package/README.md +48 -10
- package/hooks/hooks.json +1 -1
- package/hooks/register.ts +11 -1
- package/hooks/reply-hold.ts +179 -0
- package/package.json +3 -2
- package/src/cli/hook.ts +3 -3
- package/src/cli/main.ts +15 -8
- package/src/cli/reply-check.ts +43 -0
- package/src/config/schema.ts +5 -8
- package/src/dictionary/bundled-rule-data.ts +4 -2
- package/src/dictionary/load.ts +10 -6
- package/src/engine/case-fold.ts +0 -2
- package/src/engine/diff.ts +0 -5
- package/src/engine/html.ts +11 -28
- package/src/engine/identifiers.ts +13 -10
- package/src/engine/kinds.ts +12 -14
- package/src/engine/lint.ts +9 -8
- package/src/engine/markdown-syntax.ts +12 -0
- package/src/engine/markdown.ts +21 -16
- package/src/engine/paragraphs.ts +4 -6
- package/src/engine/phrase-matcher.ts +6 -5
- package/src/engine/rules/contraction.ts +5 -4
- package/src/engine/rules/dictionary.ts +9 -7
- package/src/engine/rules/marketing.ts +4 -5
- package/src/engine/rules/verb-form.ts +5 -9
- package/src/engine/sentences.ts +8 -9
- package/src/engine/suppression.ts +2 -2
- package/src/engine/types.ts +0 -7
- package/src/extension/index.ts +0 -18
- package/src/schema/parse-error.ts +6 -5
- package/src/schema/primitives.ts +0 -2
- package/src/tagger/wink.ts +6 -12
package/src/engine/html.ts
CHANGED
|
@@ -2,16 +2,9 @@ import { parser as htmlParser } from "@lezer/html"
|
|
|
2
2
|
import type { ExtractedComments, ProseBreak } from "./comments.ts"
|
|
3
3
|
import type { MarkdownHtmlComment } from "./markdown.ts"
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const IGNORED_CONTENT_TAGS = new Set(["code", "pre", "script", "style", "textarea"])
|
|
9
|
-
|
|
10
|
-
// Phrasing elements that wrap words inside one sentence.
|
|
11
|
-
// They never start a prose block, so a sentence split across them counts as one sentence.
|
|
12
|
-
// Every other element starts a prose block.
|
|
13
|
-
// That rule separates a heading, a list item, a table cell, and bare text in a `div`.
|
|
14
|
-
const INLINE_TAGS = new Set([
|
|
5
|
+
const NON_PROSE_CONTENT_TAGS = new Set(["code", "pre", "script", "style", "textarea"])
|
|
6
|
+
|
|
7
|
+
const PHRASING_TAGS_INSIDE_ONE_SENTENCE = new Set([
|
|
15
8
|
"a",
|
|
16
9
|
"abbr",
|
|
17
10
|
"b",
|
|
@@ -46,11 +39,7 @@ const INLINE_TAGS = new Set([
|
|
|
46
39
|
"wbr",
|
|
47
40
|
])
|
|
48
41
|
|
|
49
|
-
|
|
50
|
-
* Find every single-line HTML comment in the document with the lezer parser.
|
|
51
|
-
* The markdown finder cannot see comments that markdown would read as indented code.
|
|
52
|
-
*/
|
|
53
|
-
export function htmlComments(source: string): readonly MarkdownHtmlComment[] {
|
|
42
|
+
export function singleLineHtmlComments(source: string): readonly MarkdownHtmlComment[] {
|
|
54
43
|
const comments: MarkdownHtmlComment[] = []
|
|
55
44
|
const lineStarts = [0]
|
|
56
45
|
for (let offset = 0; offset < source.length; offset++) {
|
|
@@ -80,15 +69,9 @@ export function htmlComments(source: string): readonly MarkdownHtmlComment[] {
|
|
|
80
69
|
|
|
81
70
|
interface OpenElement {
|
|
82
71
|
readonly tag: string
|
|
83
|
-
readonly
|
|
72
|
+
readonly startsProseBlock: boolean
|
|
84
73
|
}
|
|
85
74
|
|
|
86
|
-
/**
|
|
87
|
-
* Keep the text nodes of an HTML document and blank every other byte with a space.
|
|
88
|
-
* Attribute values, comments, entity references, tags, and ignored element content all go.
|
|
89
|
-
* Each prose block becomes one prose break, so sentences never join across a block edge.
|
|
90
|
-
* The masked lines keep the width of the source lines, so positions stay exact.
|
|
91
|
-
*/
|
|
92
75
|
export function extractHtmlProse(source: string): ExtractedComments {
|
|
93
76
|
const keep = new Uint8Array(source.length)
|
|
94
77
|
const blockEdges: number[] = []
|
|
@@ -103,10 +86,10 @@ export function extractHtmlProse(source: string): ExtractedComments {
|
|
|
103
86
|
tagName === null || tagName === undefined
|
|
104
87
|
? ""
|
|
105
88
|
: source.slice(tagName.from, tagName.to).toLowerCase()
|
|
106
|
-
const
|
|
107
|
-
if (
|
|
108
|
-
if (
|
|
109
|
-
open.push({ tag,
|
|
89
|
+
const startsProseBlock = !PHRASING_TAGS_INSIDE_ONE_SENTENCE.has(tag)
|
|
90
|
+
if (startsProseBlock) blockEdges.push(ref.from)
|
|
91
|
+
if (NON_PROSE_CONTENT_TAGS.has(tag)) ignoreDepth += 1
|
|
92
|
+
open.push({ tag, startsProseBlock })
|
|
110
93
|
return
|
|
111
94
|
}
|
|
112
95
|
if (ref.name === "Text" && ignoreDepth === 0) keep.fill(1, ref.from, ref.to)
|
|
@@ -115,8 +98,8 @@ export function extractHtmlProse(source: string): ExtractedComments {
|
|
|
115
98
|
if (ref.name !== "Element") return
|
|
116
99
|
const element = open.pop()
|
|
117
100
|
if (element === undefined) return
|
|
118
|
-
if (
|
|
119
|
-
if (
|
|
101
|
+
if (NON_PROSE_CONTENT_TAGS.has(element.tag)) ignoreDepth -= 1
|
|
102
|
+
if (element.startsProseBlock) blockEdges.push(ref.to)
|
|
120
103
|
},
|
|
121
104
|
})
|
|
122
105
|
|
|
@@ -1,20 +1,23 @@
|
|
|
1
|
-
// A token is exempt as an identifier when it has an interior signal prose
|
|
2
|
-
// words lack: a camelCase hump, an underscore, a dotted or :: path, or a
|
|
3
|
-
// call suffix. Mixed-case prose words (brand names) are an accepted loss.
|
|
4
1
|
const IDENTIFIER_CANDIDATE =
|
|
5
2
|
/[A-Za-z_$][\w$]*(?:(?:\.|::)(?:[A-Za-z$][\w$]*|_+[A-Za-z0-9$][\w$]*))*(?:\(\))?/g
|
|
6
3
|
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
4
|
+
const hasCallSuffix = (token: string): boolean => token.endsWith("()")
|
|
5
|
+
const hasDottedPath = (token: string): boolean => token.includes(".")
|
|
6
|
+
const hasScopePath = (token: string): boolean => token.includes("::")
|
|
7
|
+
const hasCamelCaseHump = (token: string): boolean => /[a-z][A-Z]/.test(token)
|
|
8
|
+
const hasInteriorUnderscore = (token: string): boolean => /[A-Za-z0-9$]_[A-Za-z0-9_$]/.test(token)
|
|
9
|
+
|
|
10
|
+
const hasSignalThatProseWordsLack = (token: string): boolean =>
|
|
11
|
+
hasCallSuffix(token) ||
|
|
12
|
+
hasDottedPath(token) ||
|
|
13
|
+
hasScopePath(token) ||
|
|
14
|
+
hasCamelCaseHump(token) ||
|
|
15
|
+
hasInteriorUnderscore(token)
|
|
13
16
|
|
|
14
17
|
export function blankIdentifiers(lines: readonly string[]): string[] {
|
|
15
18
|
return lines.map((line) =>
|
|
16
19
|
line.replace(IDENTIFIER_CANDIDATE, (match) =>
|
|
17
|
-
|
|
20
|
+
hasSignalThatProseWordsLack(match) ? " ".repeat(match.length) : match,
|
|
18
21
|
),
|
|
19
22
|
)
|
|
20
23
|
}
|
package/src/engine/kinds.ts
CHANGED
|
@@ -7,9 +7,7 @@ export interface PathClassification {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
const PROSE: PathClassification = { kind: "prose-file", sourceDialect: "general" }
|
|
10
|
-
|
|
11
|
-
// so the writing rules would misread them as sentences (HUF-308).
|
|
12
|
-
const SKIPPED: PathClassification = { ...PROSE, skipped: true }
|
|
10
|
+
const DATA_OR_STYLE_NOT_PROSE: PathClassification = { ...PROSE, skipped: true }
|
|
13
11
|
const HTML: PathClassification = { kind: "html", sourceDialect: "general" }
|
|
14
12
|
|
|
15
13
|
const slash = (sourceDialect: SourceDialect): PathClassification => ({
|
|
@@ -51,17 +49,17 @@ const BY_EXTENSION: Readonly<Record<string, PathClassification>> = {
|
|
|
51
49
|
toml: hash("general"),
|
|
52
50
|
html: HTML,
|
|
53
51
|
htm: HTML,
|
|
54
|
-
css:
|
|
55
|
-
scss:
|
|
56
|
-
less:
|
|
57
|
-
json:
|
|
58
|
-
jsonc:
|
|
59
|
-
svg:
|
|
60
|
-
xml:
|
|
61
|
-
typ:
|
|
62
|
-
csv:
|
|
63
|
-
tsv:
|
|
64
|
-
lock:
|
|
52
|
+
css: DATA_OR_STYLE_NOT_PROSE,
|
|
53
|
+
scss: DATA_OR_STYLE_NOT_PROSE,
|
|
54
|
+
less: DATA_OR_STYLE_NOT_PROSE,
|
|
55
|
+
json: DATA_OR_STYLE_NOT_PROSE,
|
|
56
|
+
jsonc: DATA_OR_STYLE_NOT_PROSE,
|
|
57
|
+
svg: DATA_OR_STYLE_NOT_PROSE,
|
|
58
|
+
xml: DATA_OR_STYLE_NOT_PROSE,
|
|
59
|
+
typ: DATA_OR_STYLE_NOT_PROSE,
|
|
60
|
+
csv: DATA_OR_STYLE_NOT_PROSE,
|
|
61
|
+
tsv: DATA_OR_STYLE_NOT_PROSE,
|
|
62
|
+
lock: DATA_OR_STYLE_NOT_PROSE,
|
|
65
63
|
}
|
|
66
64
|
|
|
67
65
|
export const classifyPath = (path: string): PathClassification => {
|
package/src/engine/lint.ts
CHANGED
|
@@ -10,8 +10,8 @@ import { retainedRanges } from "./diff.ts"
|
|
|
10
10
|
import { newFindings, type ScopedViolation, type ViolationScope } from "./diff-match.ts"
|
|
11
11
|
import { extractHtmlProse } from "./html.ts"
|
|
12
12
|
import { blankIdentifiers } from "./identifiers.ts"
|
|
13
|
-
import { type BlockStructure, blankMarkdownForLint } from "./markdown.ts"
|
|
14
|
-
import { type Paragraph,
|
|
13
|
+
import { type BlockStructure, blankMarkdownForLint, type SentenceBoundary } from "./markdown.ts"
|
|
14
|
+
import { type Paragraph, segmentParagraphsByLeafBlock } from "./paragraphs.ts"
|
|
15
15
|
import { contraction } from "./rules/contraction.ts"
|
|
16
16
|
import { type CompiledDictionary, compileDictionary, dictionaryRule } from "./rules/dictionary.ts"
|
|
17
17
|
import { hedging } from "./rules/hedging.ts"
|
|
@@ -65,7 +65,7 @@ interface PreparedProse {
|
|
|
65
65
|
readonly wordingStructuralLines: readonly string[]
|
|
66
66
|
readonly structuralBlanks: readonly boolean[]
|
|
67
67
|
readonly wordingStructuralBlanks: readonly boolean[]
|
|
68
|
-
readonly sentenceBoundaryLines: readonly
|
|
68
|
+
readonly sentenceBoundaryLines: readonly SentenceBoundary[]
|
|
69
69
|
readonly blocks: BlockStructure
|
|
70
70
|
}
|
|
71
71
|
|
|
@@ -296,15 +296,16 @@ const lintProse = (
|
|
|
296
296
|
prepared.boundaryLines,
|
|
297
297
|
prepared.sentenceBoundaryLines,
|
|
298
298
|
)
|
|
299
|
-
const paragraphs =
|
|
299
|
+
const paragraphs = segmentParagraphsByLeafBlock(
|
|
300
300
|
prepared.structuralLines.map((line, index) => line.slice(contentStarts[index] ?? 0)),
|
|
301
301
|
contentStarts.map((contentStart) => contentStart + 1),
|
|
302
302
|
prepared.structuralBoundaryLines.map((line, index) => line.slice(contentStarts[index] ?? 0)),
|
|
303
303
|
{
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
(
|
|
307
|
-
|
|
304
|
+
leafBlockIdByLine: prepared.blocks.leafBlockIdByLine,
|
|
305
|
+
contentStartAfterContainerPrefixByLine:
|
|
306
|
+
prepared.blocks.contentStartAfterContainerPrefixByLine.map(
|
|
307
|
+
(contentStart, index) => contentStart - (contentStarts[index] ?? 0),
|
|
308
|
+
),
|
|
308
309
|
},
|
|
309
310
|
)
|
|
310
311
|
const offsets = lineOffsets(prepared.structuralLines)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { list } from "micromark-core-commonmark"
|
|
1
2
|
import {
|
|
2
3
|
asciiAlpha,
|
|
3
4
|
asciiAlphanumeric,
|
|
@@ -163,9 +164,20 @@ const rawHtmlFlow: Construct = {
|
|
|
163
164
|
tokenize: tokenizeRawHtmlFlow,
|
|
164
165
|
}
|
|
165
166
|
|
|
167
|
+
const orderedListAfterParagraph: Construct = {
|
|
168
|
+
...list,
|
|
169
|
+
tokenize(this: TokenizeContext, effects: Effects, ok: State, nok: State): State {
|
|
170
|
+
const context: TokenizeContext = Object.create(this, { interrupt: { value: false } })
|
|
171
|
+
return list.tokenize.call(context, effects, ok, nok)
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
|
|
166
175
|
export const markdownSyntaxExtension: Extension = {
|
|
167
176
|
disable: {
|
|
168
177
|
null: ["labelEnd", "labelStartImage", "labelStartLink"],
|
|
169
178
|
},
|
|
179
|
+
document: Object.fromEntries(
|
|
180
|
+
Array.from({ length: 10 }, (_, digit) => [48 + digit, orderedListAfterParagraph]),
|
|
181
|
+
),
|
|
170
182
|
flow: { 60: rawHtmlFlow },
|
|
171
183
|
}
|
package/src/engine/markdown.ts
CHANGED
|
@@ -16,15 +16,17 @@ interface MarkdownAnalysis {
|
|
|
16
16
|
readonly wordingDictionaryLines: string[]
|
|
17
17
|
readonly structuralBlanks: boolean[]
|
|
18
18
|
readonly wordingStructuralBlanks: boolean[]
|
|
19
|
-
readonly sentenceBoundaryLines:
|
|
19
|
+
readonly sentenceBoundaryLines: SentenceBoundary[]
|
|
20
20
|
readonly blocks: BlockStructure
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
export type SentenceBoundary = "none" | "stops-lookahead" | "starts-sentence"
|
|
24
|
+
|
|
25
|
+
export const NO_LEAF_BLOCK = -1
|
|
26
|
+
|
|
25
27
|
export interface BlockStructure {
|
|
26
|
-
readonly
|
|
27
|
-
readonly
|
|
28
|
+
readonly leafBlockIdByLine: readonly number[]
|
|
29
|
+
readonly contentStartAfterContainerPrefixByLine: readonly number[]
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
interface AnalysisState {
|
|
@@ -39,7 +41,7 @@ interface AnalysisState {
|
|
|
39
41
|
readonly blockQuoteMask: Uint8Array | undefined
|
|
40
42
|
readonly blockQuoteLines: Uint8Array | undefined
|
|
41
43
|
readonly structuralBlanks: boolean[]
|
|
42
|
-
readonly sentenceBoundaryLines:
|
|
44
|
+
readonly sentenceBoundaryLines: SentenceBoundary[]
|
|
43
45
|
readonly blockIds: number[]
|
|
44
46
|
readonly blockContentStarts: number[]
|
|
45
47
|
}
|
|
@@ -155,8 +157,6 @@ const SENTENCE_BOUNDARY_TOKENS = new Set([
|
|
|
155
157
|
"thematicBreak",
|
|
156
158
|
])
|
|
157
159
|
const CONTAINER_TOKENS = new Set(["blockQuotePrefix", "listItemIndent", "listItemPrefix"])
|
|
158
|
-
// One leaf block per line group. Every rule reads block structure from these ids,
|
|
159
|
-
// so no rule needs its own Markdown classifier.
|
|
160
160
|
const LEAF_BLOCK_TOKENS = new Set([
|
|
161
161
|
"atxHeading",
|
|
162
162
|
"codeFenced",
|
|
@@ -238,8 +238,8 @@ const createAnalysisState = (
|
|
|
238
238
|
blockQuoteMask: exemptBlockQuotes ? new Uint8Array(source.length) : undefined,
|
|
239
239
|
blockQuoteLines: exemptBlockQuotes ? new Uint8Array(parseLines.length) : undefined,
|
|
240
240
|
structuralBlanks: parseLines.map((line) => line.trim() === ""),
|
|
241
|
-
sentenceBoundaryLines: parseLines.map(() =>
|
|
242
|
-
blockIds: parseLines.map(() =>
|
|
241
|
+
sentenceBoundaryLines: parseLines.map(() => "none"),
|
|
242
|
+
blockIds: parseLines.map(() => NO_LEAF_BLOCK),
|
|
243
243
|
blockContentStarts: parseLines.map(() => 0),
|
|
244
244
|
}
|
|
245
245
|
}
|
|
@@ -472,8 +472,14 @@ const analyzeEvents = (state: AnalysisState, includeDictionary: boolean): void =
|
|
|
472
472
|
}
|
|
473
473
|
if (phase !== "enter") continue
|
|
474
474
|
|
|
475
|
-
|
|
476
|
-
|
|
475
|
+
const line = token.start.line - 1
|
|
476
|
+
if (token.type === "listItemPrefix") {
|
|
477
|
+
state.sentenceBoundaryLines[line] = "starts-sentence"
|
|
478
|
+
} else if (
|
|
479
|
+
SENTENCE_BOUNDARY_TOKENS.has(token.type) &&
|
|
480
|
+
state.sentenceBoundaryLines[line] === "none"
|
|
481
|
+
) {
|
|
482
|
+
state.sentenceBoundaryLines[line] = "stops-lookahead"
|
|
477
483
|
}
|
|
478
484
|
if (LEAF_BLOCK_TOKENS.has(token.type)) {
|
|
479
485
|
markLeafBlock(state, token, nextBlockId++)
|
|
@@ -483,7 +489,6 @@ const analyzeEvents = (state: AnalysisState, includeDictionary: boolean): void =
|
|
|
483
489
|
continue
|
|
484
490
|
}
|
|
485
491
|
if (CONTAINER_TOKENS.has(token.type)) {
|
|
486
|
-
const line = token.start.line - 1
|
|
487
492
|
state.blockContentStarts[line] = Math.max(
|
|
488
493
|
state.blockContentStarts[line] ?? 0,
|
|
489
494
|
token.end.column - 1,
|
|
@@ -559,7 +564,7 @@ const analyzeMarkdown = (
|
|
|
559
564
|
structuralBlanks: [],
|
|
560
565
|
wordingStructuralBlanks: [],
|
|
561
566
|
sentenceBoundaryLines: [],
|
|
562
|
-
blocks: {
|
|
567
|
+
blocks: { leafBlockIdByLine: [], contentStartAfterContainerPrefixByLine: [] },
|
|
563
568
|
}
|
|
564
569
|
}
|
|
565
570
|
|
|
@@ -634,8 +639,8 @@ const analyzeMarkdown = (
|
|
|
634
639
|
),
|
|
635
640
|
sentenceBoundaryLines: state.sentenceBoundaryLines,
|
|
636
641
|
blocks: {
|
|
637
|
-
|
|
638
|
-
|
|
642
|
+
leafBlockIdByLine: state.blockIds,
|
|
643
|
+
contentStartAfterContainerPrefixByLine: state.blockContentStarts.map(
|
|
639
644
|
(contentStart, index) => (starts[index] ?? 0) + contentStart,
|
|
640
645
|
),
|
|
641
646
|
},
|
package/src/engine/paragraphs.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type BlockStructure, NO_LEAF_BLOCK } from "./markdown.ts"
|
|
2
2
|
|
|
3
3
|
export interface Paragraph {
|
|
4
4
|
readonly lines: readonly string[]
|
|
@@ -15,9 +15,7 @@ interface OpenParagraph {
|
|
|
15
15
|
readonly boundaryLines: string[]
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
// prefix that the block parser found, so a rule reads block content only.
|
|
20
|
-
export function segmentParagraphs(
|
|
18
|
+
export function segmentParagraphsByLeafBlock(
|
|
21
19
|
lines: readonly string[],
|
|
22
20
|
columns: readonly number[],
|
|
23
21
|
boundaryLines: readonly string[],
|
|
@@ -32,7 +30,7 @@ export function segmentParagraphs(
|
|
|
32
30
|
}
|
|
33
31
|
|
|
34
32
|
lines.forEach((raw, index) => {
|
|
35
|
-
const blockId = blocks.
|
|
33
|
+
const blockId = blocks.leafBlockIdByLine[index] ?? NO_LEAF_BLOCK
|
|
36
34
|
if (blockId < 0) {
|
|
37
35
|
close()
|
|
38
36
|
return
|
|
@@ -48,7 +46,7 @@ export function segmentParagraphs(
|
|
|
48
46
|
}
|
|
49
47
|
}
|
|
50
48
|
|
|
51
|
-
const contentStart = blocks.
|
|
49
|
+
const contentStart = blocks.contentStartAfterContainerPrefixByLine[index] ?? 0
|
|
52
50
|
open.lines.push(raw.slice(contentStart))
|
|
53
51
|
open.boundaryLines.push((boundaryLines[index] ?? raw).slice(contentStart))
|
|
54
52
|
})
|
|
@@ -35,16 +35,17 @@ export interface PhraseMatch {
|
|
|
35
35
|
readonly tokenCount: number
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
export function matchPhraseAt(
|
|
38
|
+
export function matchFirstListedPhraseAt(
|
|
40
39
|
line: string,
|
|
41
40
|
tokens: readonly CaseFoldedToken[],
|
|
42
41
|
start: number,
|
|
43
|
-
|
|
42
|
+
phrasesInPriorityOrder: readonly CaseFoldedPhrase[],
|
|
44
43
|
): PhraseMatch | undefined {
|
|
45
44
|
const first = tokens[start]
|
|
46
45
|
if (first === undefined) return undefined
|
|
47
|
-
const phrase =
|
|
46
|
+
const phrase = phrasesInPriorityOrder.find((candidate) =>
|
|
47
|
+
matchesPhrase(line, tokens, start, candidate),
|
|
48
|
+
)
|
|
48
49
|
if (phrase === undefined) return undefined
|
|
49
50
|
const last = tokens[start + phrase.words.length - 1]
|
|
50
51
|
if (last === undefined) return undefined
|
|
@@ -67,7 +68,7 @@ export function scanCaseFoldedPhrases(
|
|
|
67
68
|
|
|
68
69
|
const tokens = tokenizeCaseFolded(line)
|
|
69
70
|
for (let tokenIndex = 0; tokenIndex < tokens.length; tokenIndex++) {
|
|
70
|
-
const match =
|
|
71
|
+
const match = matchFirstListedPhraseAt(line, tokens, tokenIndex, phrases)
|
|
71
72
|
if (match === undefined) continue
|
|
72
73
|
matches.push({ found: match.found, line: lineIndex + 1, column: match.offset + 1 })
|
|
73
74
|
tokenIndex += match.tokenCount - 1
|
|
@@ -4,13 +4,14 @@ import { DEFAULT_SEVERITIES } from "./registry.ts"
|
|
|
4
4
|
|
|
5
5
|
const CONTRACTION = /\b\w+['’](?:t|re|ve|ll|d|m)\b/gi
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
// contraction, so only this fixed set of stems forms a real `'s` contraction.
|
|
9
|
-
const CONTRACTION_S =
|
|
7
|
+
const S_CONTRACTION_OF_STEM_THAT_IS_NEVER_POSSESSIVE =
|
|
10
8
|
/\b(?:it|he|she|that|what|who|there|here|let|one|where|how|everyone|everybody|something|nothing|somebody|nobody)['’]s\b/gi
|
|
11
9
|
|
|
12
10
|
export function contraction(lines: readonly string[]): Violation[] {
|
|
13
|
-
return [
|
|
11
|
+
return [
|
|
12
|
+
...scanLines(lines, CONTRACTION),
|
|
13
|
+
...scanLines(lines, S_CONTRACTION_OF_STEM_THAT_IS_NEVER_POSSESSIVE),
|
|
14
|
+
].map((match) => ({
|
|
14
15
|
ruleId: "contraction",
|
|
15
16
|
severity: DEFAULT_SEVERITIES.contraction,
|
|
16
17
|
message: `Do not use a contraction. Write the words in full. Found "${match.found}".`,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { DICTIONARY_TOKEN_SOURCE } from "../../dictionary/form.ts"
|
|
2
2
|
import type { Dictionary, DictionaryData, DictionaryEntry } from "../../dictionary/schema.ts"
|
|
3
|
-
import type
|
|
3
|
+
import { type BlockStructure, NO_LEAF_BLOCK } from "../markdown.ts"
|
|
4
4
|
import type { TaggedToken, Tagger } from "../tagger.ts"
|
|
5
5
|
import type { Violation } from "../types.ts"
|
|
6
6
|
import { DEFAULT_SEVERITIES } from "./registry.ts"
|
|
@@ -48,8 +48,7 @@ export const compileDictionary = (dictionary: DictionaryData): CompiledDictionar
|
|
|
48
48
|
}
|
|
49
49
|
: { mode: "not-approved", forms: compileForms(dictionary) }
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
const isSoftLineBreak = (
|
|
51
|
+
const isLineBreakInsideOneLeafBlock = (
|
|
53
52
|
lines: readonly string[],
|
|
54
53
|
blocks: BlockStructure,
|
|
55
54
|
previous: WordToken,
|
|
@@ -64,14 +63,17 @@ const isSoftLineBreak = (
|
|
|
64
63
|
return false
|
|
65
64
|
}
|
|
66
65
|
|
|
67
|
-
const previousBlock = blocks.
|
|
68
|
-
if (previousBlock < 0 || previousBlock !== blocks.
|
|
66
|
+
const previousBlock = blocks.leafBlockIdByLine[previous.lineIndex] ?? NO_LEAF_BLOCK
|
|
67
|
+
if (previousBlock < 0 || previousBlock !== blocks.leafBlockIdByLine[token.lineIndex]) {
|
|
69
68
|
return false
|
|
70
69
|
}
|
|
71
70
|
|
|
72
71
|
const lineEnd = previousLine.endsWith("\r") ? previousLine.length - 1 : previousLine.length
|
|
73
72
|
const trailing = previousLine.slice(previous.offset + previous.text.length, lineEnd)
|
|
74
|
-
const leading = nextLine.slice(
|
|
73
|
+
const leading = nextLine.slice(
|
|
74
|
+
blocks.contentStartAfterContainerPrefixByLine[token.lineIndex] ?? 0,
|
|
75
|
+
token.offset,
|
|
76
|
+
)
|
|
75
77
|
return (trailing === "" || trailing === " ") && /^[\t ]*$/.test(leading)
|
|
76
78
|
}
|
|
77
79
|
|
|
@@ -101,7 +103,7 @@ const hasWords = (
|
|
|
101
103
|
/^\s+$/.test(line.slice(previous.offset + previous.text.length, token.offset))
|
|
102
104
|
)
|
|
103
105
|
}
|
|
104
|
-
return
|
|
106
|
+
return isLineBreakInsideOneLeafBlock(lines, blocks, previous, token)
|
|
105
107
|
})
|
|
106
108
|
|
|
107
109
|
const hasPartOfSpeech = (
|
|
@@ -3,7 +3,7 @@ import { type CaseFoldedToken, caseFoldKey, tokenizeCaseFolded } from "../case-f
|
|
|
3
3
|
import {
|
|
4
4
|
type CaseFoldedPhrase,
|
|
5
5
|
compileCaseFoldedPhrase,
|
|
6
|
-
|
|
6
|
+
matchFirstListedPhraseAt,
|
|
7
7
|
type PhraseMatch,
|
|
8
8
|
} from "../phrase-matcher.ts"
|
|
9
9
|
import type { Violation } from "../types.ts"
|
|
@@ -32,8 +32,7 @@ const marketingDataFor = (dictionary: Dictionary): CompiledMarketingData => {
|
|
|
32
32
|
return compiled
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
function findMarketingComponent(
|
|
35
|
+
function findListedWordInsideHyphenatedToken(
|
|
37
36
|
token: CaseFoldedToken,
|
|
38
37
|
componentWords: ReadonlySet<string>,
|
|
39
38
|
): PhraseMatch | undefined {
|
|
@@ -60,8 +59,8 @@ export function marketing(lines: readonly string[], dictionary: Dictionary): Vio
|
|
|
60
59
|
if (token === undefined) continue
|
|
61
60
|
|
|
62
61
|
const match =
|
|
63
|
-
|
|
64
|
-
|
|
62
|
+
matchFirstListedPhraseAt(line, tokens, tokenIndex, data.phrases) ??
|
|
63
|
+
findListedWordInsideHyphenatedToken(token, data.componentWords)
|
|
65
64
|
if (match === undefined) continue
|
|
66
65
|
|
|
67
66
|
violations.push({
|
|
@@ -10,17 +10,13 @@ const isBeForm = (token: TaggedToken) => BE_FORMS.has(token.text.toLowerCase())
|
|
|
10
10
|
|
|
11
11
|
const isPerfectAuxiliary = (token: TaggedToken) => token.pos === "AUX" && token.lemma === "have"
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
// ("was quickly closed", "were not shown") without breaking the construct.
|
|
15
|
-
const isSkippable = (token: TaggedToken) =>
|
|
13
|
+
const maySitBetweenAuxiliaryAndVerb = (token: TaggedToken) =>
|
|
16
14
|
token.pos === "ADV" || token.text.toLowerCase() === "not"
|
|
17
15
|
|
|
18
16
|
const isProgressiveVerb = (token: TaggedToken) =>
|
|
19
17
|
token.pos === "VERB" && token.text.toLowerCase().endsWith("ing")
|
|
20
18
|
|
|
21
|
-
|
|
22
|
-
// practice, which covers irregular forms (broken, sent, written) without a list.
|
|
23
|
-
const isPastParticiple = (token: TaggedToken) =>
|
|
19
|
+
const isNonProgressiveVerbAfterAuxiliary = (token: TaggedToken) =>
|
|
24
20
|
token.pos === "VERB" && !token.text.toLowerCase().endsWith("ing")
|
|
25
21
|
|
|
26
22
|
const allowlistByDictionary = new WeakMap<Dictionary, ReadonlySet<string>>()
|
|
@@ -42,7 +38,7 @@ const isAllowlisted = (token: TaggedToken, dictionary: Dictionary | undefined):
|
|
|
42
38
|
function nextContentToken(tokens: readonly TaggedToken[], start: number): TaggedToken | undefined {
|
|
43
39
|
for (let i = start; i < tokens.length; i++) {
|
|
44
40
|
const token = tokens[i]
|
|
45
|
-
if (token !== undefined && !
|
|
41
|
+
if (token !== undefined && !maySitBetweenAuxiliaryAndVerb(token)) {
|
|
46
42
|
return token
|
|
47
43
|
}
|
|
48
44
|
}
|
|
@@ -78,14 +74,14 @@ export function verbForm(
|
|
|
78
74
|
message: `Use a simple tense. Do not use the progressive. Found: "${found}".`,
|
|
79
75
|
...position,
|
|
80
76
|
})
|
|
81
|
-
} else if (isBeForm(token) &&
|
|
77
|
+
} else if (isBeForm(token) && isNonProgressiveVerbAfterAuxiliary(head) && !allowlisted) {
|
|
82
78
|
violations.push({
|
|
83
79
|
ruleId: "verb-passive",
|
|
84
80
|
severity: DEFAULT_SEVERITIES["verb-passive"],
|
|
85
81
|
message: `Use the active voice, unless the actor is unknown. Found: "${found}".`,
|
|
86
82
|
...position,
|
|
87
83
|
})
|
|
88
|
-
} else if (isPerfectAuxiliary(token) &&
|
|
84
|
+
} else if (isPerfectAuxiliary(token) && isNonProgressiveVerbAfterAuxiliary(head)) {
|
|
89
85
|
violations.push({
|
|
90
86
|
ruleId: "verb-perfect",
|
|
91
87
|
severity: DEFAULT_SEVERITIES["verb-perfect"],
|
package/src/engine/sentences.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { SentenceBoundary } from "./markdown.ts"
|
|
2
|
+
|
|
1
3
|
export interface Sentence {
|
|
2
4
|
readonly text: string
|
|
3
5
|
readonly line: number
|
|
@@ -337,9 +339,7 @@ function abbreviationIsInternal(
|
|
|
337
339
|
return isInitial
|
|
338
340
|
}
|
|
339
341
|
|
|
340
|
-
|
|
341
|
-
// Restore only listed abbreviations from the equal-length pre-identifier line.
|
|
342
|
-
function restoreAbbreviations(
|
|
342
|
+
function restoreListedAbbreviationsThatIdentifierMaskingBlanked(
|
|
343
343
|
text: string,
|
|
344
344
|
boundaryLine: string,
|
|
345
345
|
boundaryText: string,
|
|
@@ -451,15 +451,12 @@ function sentenceTerminatorEnds(
|
|
|
451
451
|
return ends
|
|
452
452
|
}
|
|
453
453
|
|
|
454
|
-
// A sentence starts at the first non-whitespace character and ends at
|
|
455
|
-
// terminal punctuation and closing delimiters, at a blank line, or at EOF.
|
|
456
|
-
// Sentences may span lines; position is where the sentence starts (1-based).
|
|
457
454
|
export function segmentSentences(
|
|
458
455
|
lines: readonly string[],
|
|
459
456
|
sourceText: string = lines.join("\n"),
|
|
460
457
|
structuralBlanks: readonly boolean[] = lines.map((line) => line.trim() === ""),
|
|
461
458
|
boundaryLines: readonly string[] = lines,
|
|
462
|
-
sentenceBoundaryLines: readonly
|
|
459
|
+
sentenceBoundaryLines: readonly SentenceBoundary[] = lines.map(() => "none"),
|
|
463
460
|
): Sentence[] {
|
|
464
461
|
const sentences: Sentence[] = []
|
|
465
462
|
const lineOffsets = [0]
|
|
@@ -472,7 +469,8 @@ export function segmentSentences(
|
|
|
472
469
|
const boundaryOffsets: number[] = []
|
|
473
470
|
const paragraphEnds: number[] = []
|
|
474
471
|
const lookaheadBreaks = lines.map(
|
|
475
|
-
(_line, index) =>
|
|
472
|
+
(_line, index) =>
|
|
473
|
+
(structuralBlanks[index] ?? true) || (sentenceBoundaryLines[index] ?? "none") !== "none",
|
|
476
474
|
)
|
|
477
475
|
let boundaryOffset = 0
|
|
478
476
|
for (const line of effectiveBoundaryLines) {
|
|
@@ -524,13 +522,14 @@ export function segmentSentences(
|
|
|
524
522
|
}
|
|
525
523
|
|
|
526
524
|
lines.forEach((maskedRaw, index) => {
|
|
525
|
+
if (sentenceBoundaryLines[index] === "starts-sentence") close()
|
|
527
526
|
if (maskedRaw.trim() === "") {
|
|
528
527
|
if (structuralBlanks[index] ?? true) close()
|
|
529
528
|
return
|
|
530
529
|
}
|
|
531
530
|
const boundaryRaw = effectiveBoundaryLines[index] ?? maskedRaw
|
|
532
531
|
const currentBoundaryOffset = boundaryOffsets[index] ?? 0
|
|
533
|
-
const raw =
|
|
532
|
+
const raw = restoreListedAbbreviationsThatIdentifierMaskingBlanked(
|
|
534
533
|
maskedRaw,
|
|
535
534
|
boundaryRaw,
|
|
536
535
|
boundaryText,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { LineCommentSpan } from "./comments.ts"
|
|
2
2
|
import type { ScopedViolation } from "./diff-match.ts"
|
|
3
|
-
import {
|
|
3
|
+
import { singleLineHtmlComments } from "./html.ts"
|
|
4
4
|
import { type MarkdownHtmlComment, markdownHtmlComments } from "./markdown.ts"
|
|
5
5
|
import { DEFAULT_SEVERITIES, type RuleId, ruleIds } from "./rules/registry.ts"
|
|
6
6
|
import { lineOffsets } from "./scan.ts"
|
|
@@ -135,7 +135,7 @@ export function analyzeSuppressions(
|
|
|
135
135
|
kind === "prose-file"
|
|
136
136
|
? commentCandidates(markdownHtmlComments(text))
|
|
137
137
|
: kind === "html"
|
|
138
|
-
? commentCandidates(
|
|
138
|
+
? commentCandidates(singleLineHtmlComments(text))
|
|
139
139
|
: kind === "slash-source" || kind === "hash-source"
|
|
140
140
|
? sourceCandidates(lines, lineComments)
|
|
141
141
|
: []
|
package/src/engine/types.ts
CHANGED
|
@@ -37,15 +37,8 @@ export interface LintOptions {
|
|
|
37
37
|
readonly exemptBlockQuotes?: boolean
|
|
38
38
|
readonly dictionary?: DictionaryData
|
|
39
39
|
readonly ruleData?: RuleData
|
|
40
|
-
// POS tagger for the verb-form rules and POS-aware dictionary entries.
|
|
41
40
|
readonly tagger?: Tagger
|
|
42
41
|
readonly sourceDialect?: SourceDialect
|
|
43
|
-
/**
|
|
44
|
-
* The previous document text used to report only new violations.
|
|
45
|
-
* The engine compares sentence-scoped and paragraph-scoped violations structurally.
|
|
46
|
-
* Omit this value to lint the current document in full.
|
|
47
|
-
* Violation positions refer to the current text.
|
|
48
|
-
*/
|
|
49
42
|
readonly previousText?: string
|
|
50
43
|
}
|
|
51
44
|
|
package/src/extension/index.ts
CHANGED
|
@@ -237,24 +237,6 @@ function enforceStrictEvent(state: SessionState, eventValue: unknown): void {
|
|
|
237
237
|
}
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
-
// Strict mode must have the last word on user-facing text, and the public hooks cannot give it.
|
|
241
|
-
// `emit` drops handler results for every event except `session_before_*` (pi
|
|
242
|
-
// dist/core/extensions/runner.js:623-652), and `message_end` and `tool_result` chain
|
|
243
|
-
// last-writer-wins in extension load order (pi docs/extensions.md:848-851). No `pi.on` overload
|
|
244
|
-
// takes a priority, so a later extension can restore prose that this extension removed.
|
|
245
|
-
// These patches run after the whole handler loop, which is the only remaining seam.
|
|
246
|
-
//
|
|
247
|
-
// Four assumptions hold this together, and each one fails silently.
|
|
248
|
-
// `test/extension/extension.test.ts` guards them:
|
|
249
|
-
// 1. pi still calls `emit`, `emitMessageEnd`, and `emitToolResult`. A rename leaves the patch on
|
|
250
|
-
// the prototype, but pi never invokes it, and redaction stops with no error.
|
|
251
|
-
// 2. The extension and the host share one `ExtensionRunner` class object. The pi loader aliases
|
|
252
|
-
// the package specifier to the module the host runs, so this prototype is the live one.
|
|
253
|
-
// 3. `createContext().sessionManager` keeps a stable identity, because it is the WeakMap key.
|
|
254
|
-
// A per-call wrapper makes every lookup miss.
|
|
255
|
-
// 4. `pi.on("tool_result")` stays registered. AgentSession gates the call behind
|
|
256
|
-
// `hasHandlers("tool_result")` (pi dist/core/agent-session.js:246), so a drop of that handler
|
|
257
|
-
// makes the `emitToolResult` patch dead.
|
|
258
240
|
function installStrictOutputBoundary(): void {
|
|
259
241
|
if (boundaryRegistry.installed) return
|
|
260
242
|
boundaryRegistry.installed = true
|