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.
@@ -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
- // These elements hold code, markup, or preformatted content, never prose.
6
- // `code` is also inline, so a semicolon in inline code stays quiet while the
7
- // sentence around it still reads as one sentence.
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 inline: boolean
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 inline = INLINE_TAGS.has(tag)
107
- if (!inline) blockEdges.push(ref.from)
108
- if (IGNORED_CONTENT_TAGS.has(tag)) ignoreDepth += 1
109
- open.push({ tag, inline })
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 (IGNORED_CONTENT_TAGS.has(element.tag)) ignoreDepth -= 1
119
- if (!element.inline) blockEdges.push(ref.to)
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 isIdentifier = (token: string): boolean =>
8
- token.endsWith("()") ||
9
- token.includes(".") ||
10
- token.includes("::") ||
11
- /[a-z][A-Z]/.test(token) ||
12
- /[A-Za-z0-9$]_[A-Za-z0-9_$]/.test(token)
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
- isIdentifier(match) ? " ".repeat(match.length) : match,
20
+ hasSignalThatProseWordsLack(match) ? " ".repeat(match.length) : match,
18
21
  ),
19
22
  )
20
23
  }
@@ -7,9 +7,7 @@ export interface PathClassification {
7
7
  }
8
8
 
9
9
  const PROSE: PathClassification = { kind: "prose-file", sourceDialect: "general" }
10
- // Data and style files carry rules and structured data rather than prose,
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: SKIPPED,
55
- scss: SKIPPED,
56
- less: SKIPPED,
57
- json: SKIPPED,
58
- jsonc: SKIPPED,
59
- svg: SKIPPED,
60
- xml: SKIPPED,
61
- typ: SKIPPED,
62
- csv: SKIPPED,
63
- tsv: SKIPPED,
64
- lock: SKIPPED,
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 => {
@@ -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, segmentParagraphs } from "./paragraphs.ts"
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 boolean[]
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 = segmentParagraphs(
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
- ids: prepared.blocks.ids,
305
- contentStarts: prepared.blocks.contentStarts.map(
306
- (contentStart, index) => contentStart - (contentStarts[index] ?? 0),
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
  }
@@ -16,15 +16,17 @@ interface MarkdownAnalysis {
16
16
  readonly wordingDictionaryLines: string[]
17
17
  readonly structuralBlanks: boolean[]
18
18
  readonly wordingStructuralBlanks: boolean[]
19
- readonly sentenceBoundaryLines: boolean[]
19
+ readonly sentenceBoundaryLines: SentenceBoundary[]
20
20
  readonly blocks: BlockStructure
21
21
  }
22
22
 
23
- // Per line: the id of the leaf block that owns it, or -1 when no block does, and
24
- // the column where the block content starts after any quote or list prefix.
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 ids: readonly number[]
27
- readonly contentStarts: readonly number[]
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: boolean[]
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(() => false),
242
- blockIds: parseLines.map(() => -1),
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
- if (SENTENCE_BOUNDARY_TOKENS.has(token.type)) {
476
- state.sentenceBoundaryLines[token.start.line - 1] = true
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: { ids: [], contentStarts: [] },
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
- ids: state.blockIds,
638
- contentStarts: state.blockContentStarts.map(
642
+ leafBlockIdByLine: state.blockIds,
643
+ contentStartAfterContainerPrefixByLine: state.blockContentStarts.map(
639
644
  (contentStart, index) => (starts[index] ?? 0) + contentStart,
640
645
  ),
641
646
  },
@@ -1,4 +1,4 @@
1
- import type { BlockStructure } from "./markdown.ts"
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
- // One paragraph per Markdown leaf block. Each line drops the quote and list
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.ids[index] ?? -1
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.contentStarts[index] ?? 0
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
- // Longest phrase first when callers sort their list that way.
39
- export function matchPhraseAt(
38
+ export function matchFirstListedPhraseAt(
40
39
  line: string,
41
40
  tokens: readonly CaseFoldedToken[],
42
41
  start: number,
43
- phrases: readonly CaseFoldedPhrase[],
42
+ phrasesInPriorityOrder: readonly CaseFoldedPhrase[],
44
43
  ): PhraseMatch | undefined {
45
44
  const first = tokens[start]
46
45
  if (first === undefined) return undefined
47
- const phrase = phrases.find((candidate) => matchesPhrase(line, tokens, start, candidate))
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 = matchPhraseAt(line, tokens, tokenIndex, phrases)
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
- // `'s` is the only ambiguous case: "repo's" is a possessive, not a
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 [...scanLines(lines, CONTRACTION), ...scanLines(lines, CONTRACTION_S)].map((match) => ({
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 { BlockStructure } from "../markdown.ts"
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
- // Two words join across a line break only inside one Markdown leaf block.
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.ids[previous.lineIndex] ?? -1
68
- if (previousBlock < 0 || previousBlock !== blocks.ids[token.lineIndex]) {
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(blocks.contentStarts[token.lineIndex] ?? 0, token.offset)
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 isSoftLineBreak(lines, blocks, previous, token)
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
- matchPhraseAt,
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
- // A listed single word inside a hyphenated token ("world-class-ready") still counts.
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
- matchPhraseAt(line, tokens, tokenIndex, data.phrases) ??
64
- findMarketingComponent(token, data.componentWords)
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
- // Adverbs and "not" may sit between the auxiliary and its verb
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
- // After a be/have auxiliary, any non-"ing" verb is a past participle in
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 && !isSkippable(token)) {
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) && isPastParticiple(head) && !allowlisted) {
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) && isPastParticiple(head)) {
84
+ } else if (isPerfectAuxiliary(token) && isNonProgressiveVerbAfterAuxiliary(head)) {
89
85
  violations.push({
90
86
  ruleId: "verb-perfect",
91
87
  severity: DEFAULT_SEVERITIES["verb-perfect"],
@@ -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
- // Identifier masking blanks dotted abbreviations but leaves their final periods.
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 boolean[] = lines.map(() => false),
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) => (structuralBlanks[index] ?? true) || (sentenceBoundaryLines[index] ?? false),
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 = restoreAbbreviations(
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 { htmlComments } from "./html.ts"
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(htmlComments(text))
138
+ ? commentCandidates(singleLineHtmlComments(text))
139
139
  : kind === "slash-source" || kind === "hash-source"
140
140
  ? sourceCandidates(lines, lineComments)
141
141
  : []
@@ -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
 
@@ -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