agent-simple-english 0.5.0 → 0.5.1

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.
@@ -11,7 +11,7 @@
11
11
  {
12
12
  "name": "simple-english",
13
13
  "description": "Apply technical and house-style writing rules to writes, edits, and git commit messages.",
14
- "version": "0.5.0",
14
+ "version": "0.5.1",
15
15
  "author": {
16
16
  "name": "JIA YI"
17
17
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "simple-english",
4
- "version": "0.5.0",
4
+ "version": "0.5.1",
5
5
  "description": "Apply technical and house-style writing rules to writes, edits, and git commit messages.",
6
6
  "repository": "https://github.com/jyooi/agent-simple-english",
7
7
  "license": "MIT",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-simple-english",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Technical and house-style English lint engine, CLI, and host adapters",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -54,7 +54,6 @@
54
54
  "micromark-util-html-tag-name": "^2.0.1",
55
55
  "micromark-util-normalize-identifier": "^2.0.1",
56
56
  "micromark-util-types": "^2.0.2",
57
- "unicode-case-folding": "^1.1.1",
58
57
  "wink-eng-lite-web-model": "^1.8.1",
59
58
  "wink-nlp": "^2.4.0"
60
59
  },
@@ -2,7 +2,6 @@ import type { RuleId } from "../engine/rules/registry.ts"
2
2
  import type { Violation } from "../engine/types.ts"
3
3
 
4
4
  function suggestedFix(violation: Violation): string {
5
- if (violation.suggestion !== undefined) return `Use "${violation.suggestion}".`
6
5
  if (violation.suggestions !== undefined && violation.suggestions.length > 0) {
7
6
  return `Use one of these approved alternatives: ${violation.suggestions.map((item) => `"${item}"`).join(", ")}.`
8
7
  }
@@ -39,3 +38,12 @@ export function formatViolations(
39
38
  ): string {
40
39
  return `${heading} ${path}:\n${violationDetails(violations)}`
41
40
  }
41
+
42
+ export function splitViolations<V extends Violation>(
43
+ violations: readonly V[],
44
+ ): { readonly hard: V[]; readonly soft: V[] } {
45
+ return {
46
+ hard: violations.filter((violation) => violation.severity === "hard"),
47
+ soft: violations.filter((violation) => violation.severity === "soft"),
48
+ }
49
+ }
package/src/cli/hook.ts CHANGED
@@ -3,7 +3,7 @@ import { open, readFile } from "node:fs/promises"
3
3
  import { resolve } from "node:path"
4
4
  import { Cause, Effect } from "effect"
5
5
  import { blankCommitMetadata, findCommitInvocations } from "../adapter/commit-message.ts"
6
- import { formatViolations } from "../adapter/feedback.ts"
6
+ import { formatViolations, splitViolations } from "../adapter/feedback.ts"
7
7
  import { ruleSummary } from "../adapter/rule-summary.ts"
8
8
  import { loadConfig } from "../config/load.ts"
9
9
  import { loadConfiguredDictionary } from "../dictionary/configured.ts"
@@ -24,6 +24,7 @@ import {
24
24
  hasProcessedReply,
25
25
  setReplyFeedback,
26
26
  } from "./session-state.ts"
27
+ import { tryAsync } from "./try-async.ts"
27
28
 
28
29
  interface CommonEvent {
29
30
  readonly cwd: string
@@ -244,49 +245,13 @@ function proposedEdit(
244
245
  }
245
246
 
246
247
  const readEditFile = (path: string) =>
247
- Effect.tryPromise({
248
- try: () => readFile(path, "utf8"),
249
- catch: (cause) => new Error(`cannot read edit file ${path}: ${cause}`),
250
- })
248
+ tryAsync(`cannot read edit file ${path}`, () => readFile(path, "utf8"))
251
249
 
252
250
  interface AssistantReply {
253
251
  readonly identity: string
254
252
  readonly text: string
255
253
  }
256
254
 
257
- function assistantReply(line: string, path: string, offset: number): AssistantReply | undefined {
258
- let value: unknown
259
- try {
260
- value = JSON.parse(line) as unknown
261
- } catch {
262
- return undefined
263
- }
264
- if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined
265
- const entry = value as Record<string, unknown>
266
- if (entry.type !== "assistant") return undefined
267
- const message = record(entry.message, "assistant transcript message")
268
- const content = message.content
269
- if (!Array.isArray(content)) {
270
- throw new Error(`assistant transcript message in ${path} must contain content blocks`)
271
- }
272
- const uuid = entry.uuid
273
- const identity =
274
- typeof uuid === "string" && uuid.length > 0
275
- ? `uuid:${uuid}`
276
- : `offset:${offset}:${createHash("sha256").update(line).digest("hex")}`
277
- const text = content
278
- .filter(
279
- (block): block is { type: "text"; text: string } =>
280
- typeof block === "object" &&
281
- block !== null &&
282
- (block as Record<string, unknown>).type === "text" &&
283
- typeof (block as Record<string, unknown>).text === "string",
284
- )
285
- .map((block) => block.text)
286
- .join("\n")
287
- return { identity, text }
288
- }
289
-
290
255
  const TRANSCRIPT_CHUNK_SIZE = 64 * 1024
291
256
  const TRANSCRIPT_ENTRY_HEADER_SIZE = 64 * 1024
292
257
 
@@ -384,23 +349,6 @@ async function readTranscriptRange(
384
349
  return buffer
385
350
  }
386
351
 
387
- async function assistantReplyInRange(
388
- file: Awaited<ReturnType<typeof open>>,
389
- path: string,
390
- start: number,
391
- end: number,
392
- ): Promise<AssistantReply | undefined> {
393
- const length = end - start
394
- const headerLength = Math.min(length, TRANSCRIPT_ENTRY_HEADER_SIZE)
395
- const header = await readTranscriptRange(file, path, start, headerLength)
396
- if (transcriptEntryKind(header.toString("utf8")) !== "assistant") return undefined
397
- const line =
398
- headerLength === length
399
- ? header.toString("utf8")
400
- : (await readTranscriptRange(file, path, start, length)).toString("utf8")
401
- return assistantReply(line, path, start)
402
- }
403
-
404
352
  async function turnIdentityInRange(
405
353
  file: Awaited<ReturnType<typeof open>>,
406
354
  path: string,
@@ -464,12 +412,6 @@ async function latestTranscriptEntry<T>(
464
412
  }
465
413
  }
466
414
 
467
- async function assistantReplyFromTranscript(path: string): Promise<AssistantReply> {
468
- const reply = await latestTranscriptEntry(path, assistantReplyInRange)
469
- if (reply !== undefined) return reply
470
- throw new Error(`cannot find an assistant reply in ${path}`)
471
- }
472
-
473
415
  async function assistantReplyFromEvent(text: string, path: string): Promise<AssistantReply> {
474
416
  const turnIdentity = await latestTranscriptEntry(path, turnIdentityInRange)
475
417
  if (turnIdentity === undefined) throw new Error(`cannot find a reply turn in ${path}`)
@@ -477,45 +419,28 @@ async function assistantReplyFromEvent(text: string, path: string): Promise<Assi
477
419
  return { identity: `${turnIdentity}:reply:${textHash}`, text }
478
420
  }
479
421
 
480
- const readAssistantReply = (path: string) =>
481
- Effect.tryPromise({
482
- try: () => assistantReplyFromTranscript(path),
483
- catch: (cause) => new Error(`cannot read assistant reply from ${path}: ${cause}`),
484
- })
485
-
486
422
  const readEventAssistantReply = (text: string, path: string) =>
487
- Effect.tryPromise({
488
- try: () => assistantReplyFromEvent(text, path),
489
- catch: (cause) => new Error(`cannot read assistant reply turn from ${path}: ${cause}`),
490
- })
423
+ tryAsync(`cannot read assistant reply turn from ${path}`, () =>
424
+ assistantReplyFromEvent(text, path),
425
+ )
491
426
 
492
427
  const replyWasProcessed = (sessionId: string, replyIdentity: string) =>
493
- Effect.tryPromise({
494
- try: () => hasProcessedReply(sessionId, replyIdentity),
495
- catch: (cause) => new Error(`cannot read session state: ${cause}`),
496
- })
428
+ tryAsync("cannot read session state", () => hasProcessedReply(sessionId, replyIdentity))
497
429
 
498
430
  const updateReplyFeedback = (
499
431
  sessionId: string,
500
432
  replyIdentity: string,
501
433
  feedback: string | undefined,
502
434
  ) =>
503
- Effect.tryPromise({
504
- try: () => setReplyFeedback(sessionId, replyIdentity, feedback),
505
- catch: (cause) => new Error(`cannot update session state: ${cause}`),
506
- })
435
+ tryAsync("cannot update session state", () =>
436
+ setReplyFeedback(sessionId, replyIdentity, feedback),
437
+ )
507
438
 
508
439
  const takePendingFeedback = (sessionId: string) =>
509
- Effect.tryPromise({
510
- try: () => consumePendingFeedback(sessionId),
511
- catch: (cause) => new Error(`cannot read session state: ${cause}`),
512
- })
440
+ tryAsync("cannot read session state", () => consumePendingFeedback(sessionId))
513
441
 
514
442
  const readSessionControl = (sessionId: string) =>
515
- Effect.tryPromise({
516
- try: () => getSessionControl(sessionId),
517
- catch: (cause) => new Error(`cannot read session state: ${cause}`),
518
- })
443
+ tryAsync("cannot read session state", () => getSessionControl(sessionId))
519
444
 
520
445
  const loadLintOptions = (cwd: string, tagger: Tagger) =>
521
446
  Effect.gen(function* () {
@@ -532,16 +457,6 @@ const loadLintOptions = (cwd: string, tagger: Tagger) =>
532
457
  return { ...config, dictionary, ruleData, tagger } satisfies LintOptions
533
458
  })
534
459
 
535
- function splitViolations(violations: readonly ReportViolation[]): {
536
- readonly hard: ReportViolation[]
537
- readonly soft: ReportViolation[]
538
- } {
539
- return {
540
- hard: violations.filter((violation) => violation.severity === "hard"),
541
- soft: violations.filter((violation) => violation.severity === "soft"),
542
- }
543
- }
544
-
545
460
  type EvaluationObservation = Omit<ObservationDraft, "sessionId" | "cwd">
546
461
 
547
462
  interface HookEvaluation {
@@ -603,15 +518,18 @@ function textDecision(
603
518
 
604
519
  function evaluateReply(event: StopEvent, tagger: Tagger): Effect.Effect<HookEvaluation, Error> {
605
520
  return Effect.gen(function* () {
606
- const reply = yield* event.lastAssistantMessage === undefined
607
- ? readAssistantReply(event.transcriptPath)
608
- : readEventAssistantReply(event.lastAssistantMessage, event.transcriptPath)
521
+ if (event.lastAssistantMessage === undefined) {
522
+ return yield* Effect.fail(
523
+ new Error(`last_assistant_message was not provided for ${event.transcriptPath}`),
524
+ )
525
+ }
526
+ const reply = yield* readEventAssistantReply(event.lastAssistantMessage, event.transcriptPath)
609
527
  if (yield* replyWasProcessed(event.sessionId, reply.identity)) {
610
528
  return { output: {} as Record<string, never> }
611
529
  }
612
530
  const options = yield* loadLintOptions(event.cwd, tagger)
613
531
  const violations = lint("prose-file", reply.text, options).violations
614
- const hard = violations.filter((violation) => violation.severity === "hard")
532
+ const { hard } = splitViolations(violations)
615
533
  const feedback =
616
534
  hard.length === 0
617
535
  ? undefined
package/src/cli/main.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  import { readFile } from "node:fs/promises"
3
3
  import { Effect, Result } from "effect"
4
4
  import packageManifest from "../../package.json" with { type: "json" }
5
+ import { splitViolations } from "../adapter/feedback.ts"
5
6
  import { loadConfig } from "../config/load.ts"
6
7
  import { loadConfiguredDictionary } from "../dictionary/configured.ts"
7
8
  import { loadRuleData } from "../dictionary/load.ts"
@@ -102,7 +103,6 @@ interface FileViolation {
102
103
  readonly suggestions?: readonly string[]
103
104
  readonly line: number
104
105
  readonly column: number
105
- readonly suggestion?: string
106
106
  }
107
107
 
108
108
  interface CliReport {
@@ -138,7 +138,7 @@ const toCliReport = (
138
138
  violations,
139
139
  summary: {
140
140
  total: violations.length,
141
- hard: violations.filter((violation) => violation.severity === "hard").length,
141
+ hard: splitViolations(violations).hard.length,
142
142
  },
143
143
  skipped,
144
144
  }
@@ -3,6 +3,7 @@ import { mkdir, open, readdir, readFile } from "node:fs/promises"
3
3
  import { join } from "node:path"
4
4
  import { createInterface } from "node:readline"
5
5
  import type { LintKind, ReportViolation } from "../engine/types.ts"
6
+ import { isFileError } from "../fs-error.ts"
6
7
  import { applicationStateDirectory } from "./state-directory.ts"
7
8
 
8
9
  export type ObservationEvent = "write" | "edit" | "commit-message" | "reply"
@@ -56,12 +57,6 @@ const observationsDirectory = (): string => join(applicationStateDirectory(), "o
56
57
 
57
58
  const verdictsPath = (): string => join(applicationStateDirectory(), "verdicts.jsonl")
58
59
 
59
- function isFileError(cause: unknown, code: string): boolean {
60
- return (
61
- typeof cause === "object" && cause !== null && (cause as NodeJS.ErrnoException).code === code
62
- )
63
- }
64
-
65
60
  async function appendJsonLine(path: string, value: unknown): Promise<void> {
66
61
  await mkdir(applicationStateDirectory(), { recursive: true, mode: 0o700 })
67
62
  const file = await open(path, "a", 0o600)
@@ -1,6 +1,8 @@
1
1
  import { createHash, randomUUID } from "node:crypto"
2
2
  import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"
3
3
  import { join } from "node:path"
4
+ import { setTimeout as wait } from "node:timers/promises"
5
+ import { isFileError } from "../fs-error.ts"
4
6
  import { applicationStateDirectory } from "./state-directory.ts"
5
7
 
6
8
  export interface SessionControl {
@@ -19,9 +21,6 @@ const LOCK_STALE_MILLISECONDS = 10_000
19
21
  const LOCK_RETRY_MILLISECONDS = 5
20
22
  const LOCK_RETRIES = 200
21
23
 
22
- const isFileError = (cause: unknown, code: string): boolean =>
23
- typeof cause === "object" && cause !== null && (cause as { code?: string }).code === code
24
-
25
24
  const sessionsDirectory = (): string => join(applicationStateDirectory(), "sessions")
26
25
 
27
26
  const sessionKey = (sessionId: string): string =>
@@ -41,27 +40,16 @@ function optionalString(state: Record<string, unknown>, name: string): string |
41
40
  return value as string | undefined
42
41
  }
43
42
 
44
- function decodeState(text: string, path: string): SessionState {
43
+ function decodeState(text: string, path: string): SessionState | undefined {
45
44
  const value = JSON.parse(text) as unknown
46
45
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
47
46
  throw new Error(`invalid session state in ${path}`)
48
47
  }
49
48
  const state = value as Record<string, unknown>
49
+ if (state.version !== 3) return undefined
50
50
  const pendingFeedback = optionalString(state, "pendingFeedback")
51
- if (state.version === 1 && typeof pendingFeedback === "string") {
52
- return { version: 3, ...DEFAULT_CONTROL, pendingFeedback }
53
- }
54
51
  const lastProcessedReply = optionalString(state, "lastProcessedReply")
55
- if (state.version === 2 && lastProcessedReply !== undefined && lastProcessedReply.length > 0) {
56
- return {
57
- version: 3,
58
- ...DEFAULT_CONTROL,
59
- lastProcessedReply,
60
- ...(pendingFeedback === undefined ? {} : { pendingFeedback }),
61
- }
62
- }
63
52
  if (
64
- state.version !== 3 ||
65
53
  typeof state.enabled !== "boolean" ||
66
54
  typeof state.strict !== "boolean" ||
67
55
  (state.strict && !state.enabled) ||
@@ -100,9 +88,6 @@ async function writeState(sessionId: string, state: SessionState): Promise<void>
100
88
  }
101
89
  }
102
90
 
103
- const wait = (milliseconds: number): Promise<void> =>
104
- new Promise((resolve) => setTimeout(resolve, milliseconds))
105
-
106
91
  async function acquireLock(sessionId: string): Promise<() => Promise<void>> {
107
92
  const directory = sessionsDirectory()
108
93
  const path = lockPath(sessionId)
@@ -0,0 +1,7 @@
1
+ import { Effect } from "effect"
2
+
3
+ export const tryAsync = <T>(label: string, run: () => Promise<T>): Effect.Effect<T, Error> =>
4
+ Effect.tryPromise({
5
+ try: run,
6
+ catch: (cause) => new Error(`${label}: ${cause}`),
7
+ })
@@ -2,6 +2,7 @@ import { readFile } from "node:fs/promises"
2
2
  import { homedir } from "node:os"
3
3
  import { isAbsolute, join, resolve } from "node:path"
4
4
  import { Effect } from "effect"
5
+ import { isFileError } from "../fs-error.ts"
5
6
  import { mergeConfigs } from "./merge.ts"
6
7
  import { ConfigError, decodeConfig, type SteConfig } from "./schema.ts"
7
8
 
@@ -32,9 +33,6 @@ export const legacyGlobalConfigPath = (cwd = process.cwd()): string =>
32
33
  export const legacyProjectConfigPath = (cwd: string): string =>
33
34
  join(cwd, ".pi", "simple-english.json")
34
35
 
35
- const isMissingFile = (cause: unknown): boolean =>
36
- typeof cause === "object" && cause !== null && (cause as { code?: string }).code === "ENOENT"
37
-
38
36
  const readConfigFile = (
39
37
  path: string,
40
38
  optional: boolean,
@@ -45,7 +43,7 @@ const readConfigFile = (
45
43
  }).pipe(
46
44
  Effect.matchEffect({
47
45
  onFailure: (cause) =>
48
- optional && isMissingFile(cause)
46
+ optional && isFileError(cause, "ENOENT")
49
47
  ? Effect.succeed(undefined)
50
48
  : Effect.fail(new ConfigError(`cannot read config file ${path}: ${cause}`)),
51
49
  onSuccess: (text) =>
@@ -1,4 +1,3 @@
1
- import { caseFold } from "unicode-case-folding"
2
1
  import { TOKEN_RUN_PATTERN } from "./tokens.ts"
3
2
 
4
3
  export interface CaseFoldedToken {
@@ -7,7 +6,9 @@ export interface CaseFoldedToken {
7
6
  readonly offset: number
8
7
  }
9
8
 
10
- export const caseFoldKey = (text: string): string => caseFold(text)
9
+ // ponytail: toLowerCase() does not fold "ss" from sharp s or merge sigma forms.
10
+ // Add unicode-case-folding back if a dictionary needs that reach.
11
+ export const caseFoldKey = (text: string): string => text.toLowerCase()
11
12
 
12
13
  export const tokenizeCaseFolded = (line: string): readonly CaseFoldedToken[] =>
13
14
  Array.from(line.matchAll(TOKEN_RUN_PATTERN), (match) => ({
@@ -10,7 +10,7 @@ import { changedText } 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 { blankMarkdownForLint } from "./markdown.ts"
13
+ import { type BlockStructure, blankMarkdownForLint } from "./markdown.ts"
14
14
  import { type Paragraph, segmentParagraphs } from "./paragraphs.ts"
15
15
  import { contraction } from "./rules/contraction.ts"
16
16
  import { type CompiledDictionary, compileDictionary, dictionaryRule } from "./rules/dictionary.ts"
@@ -65,6 +65,7 @@ interface PreparedProse {
65
65
  readonly structuralBlanks: readonly boolean[]
66
66
  readonly wordingStructuralBlanks: readonly boolean[]
67
67
  readonly sentenceBoundaryLines: readonly boolean[]
68
+ readonly blocks: BlockStructure
68
69
  }
69
70
 
70
71
  interface SentenceScopeIndex {
@@ -192,6 +193,7 @@ const prepareProse = (
192
193
  structuralBlanks: markdown.structuralBlanks,
193
194
  wordingStructuralBlanks: markdown.wordingStructuralBlanks,
194
195
  sentenceBoundaryLines: markdown.sentenceBoundaryLines,
196
+ blocks: markdown.blocks,
195
197
  }
196
198
  }
197
199
 
@@ -312,6 +314,12 @@ const lintProse = (
312
314
  prepared.structuralLines.map((line, index) => line.slice(contentStarts[index] ?? 0)),
313
315
  contentStarts.map((contentStart) => contentStart + 1),
314
316
  prepared.structuralBoundaryLines.map((line, index) => line.slice(contentStarts[index] ?? 0)),
317
+ {
318
+ ids: prepared.blocks.ids,
319
+ contentStarts: prepared.blocks.contentStarts.map(
320
+ (contentStart, index) => contentStart - (contentStarts[index] ?? 0),
321
+ ),
322
+ },
315
323
  )
316
324
  const offsets = lineOffsets(prepared.structuralLines)
317
325
  const sentenceIndex = indexSentenceScopes(sentences, prepared.lines.length, sourceOffset)
@@ -399,8 +407,8 @@ const lintProse = (
399
407
  dictionaryRule(
400
408
  prepared.wordingStructuralLines,
401
409
  options.dictionary,
410
+ prepared.blocks,
402
411
  options.tagger,
403
- contentStarts,
404
412
  prepared.wordingDictionaryLines,
405
413
  ),
406
414
  dictionarySentenceIndex,
@@ -16,12 +16,14 @@ interface MarkdownAnalysis {
16
16
  readonly structuralBlanks: boolean[]
17
17
  readonly wordingStructuralBlanks: boolean[]
18
18
  readonly sentenceBoundaryLines: boolean[]
19
+ readonly blocks: BlockStructure
19
20
  }
20
21
 
21
- export interface MarkdownCodeResult {
22
- readonly lines: string[]
23
- readonly structuralLines: string[]
24
- readonly structuralBlanks: boolean[]
22
+ // Per line: the id of the leaf block that owns it, or -1 when no block does, and
23
+ // the column where the block content starts after any quote or list prefix.
24
+ export interface BlockStructure {
25
+ readonly ids: readonly number[]
26
+ readonly contentStarts: readonly number[]
25
27
  }
26
28
 
27
29
  interface AnalysisState {
@@ -37,6 +39,8 @@ interface AnalysisState {
37
39
  readonly blockQuoteLines: Uint8Array | undefined
38
40
  readonly structuralBlanks: boolean[]
39
41
  readonly sentenceBoundaryLines: boolean[]
42
+ readonly blockIds: number[]
43
+ readonly blockContentStarts: number[]
40
44
  }
41
45
 
42
46
  interface SourceRange {
@@ -153,6 +157,22 @@ const SENTENCE_BOUNDARY_TOKENS = new Set([
153
157
  "thematicBreak",
154
158
  ])
155
159
  const CONTAINER_TOKENS = new Set(["blockQuotePrefix", "listItemIndent", "listItemPrefix"])
160
+ // One leaf block per line group. Every rule reads block structure from these ids,
161
+ // so no rule needs its own Markdown classifier.
162
+ const LEAF_BLOCK_TOKENS = new Set([
163
+ "atxHeading",
164
+ "codeFenced",
165
+ "codeIndented",
166
+ "definition",
167
+ "htmlFlow",
168
+ "htmlRawFlow",
169
+ "paragraph",
170
+ "setextHeadingLine",
171
+ "setextHeadingText",
172
+ "table",
173
+ "thematicBreak",
174
+ "yaml",
175
+ ])
156
176
  const DICTIONARY_TOKENS = new Set([
157
177
  "atxHeadingSequence",
158
178
  "autolink",
@@ -221,6 +241,15 @@ const createAnalysisState = (
221
241
  blockQuoteLines: exemptBlockQuotes ? new Uint8Array(parseLines.length) : undefined,
222
242
  structuralBlanks: parseLines.map((line) => line.trim() === ""),
223
243
  sentenceBoundaryLines: parseLines.map(() => false),
244
+ blockIds: parseLines.map(() => -1),
245
+ blockContentStarts: parseLines.map(() => 0),
246
+ }
247
+ }
248
+
249
+ const markLeafBlock = (state: AnalysisState, token: MarkdownToken, blockId: number): void => {
250
+ const lastLine = Math.min(token.end.line - 1, state.parseLines.length - 1)
251
+ for (let line = token.start.line - 1; line <= lastLine; line++) {
252
+ state.blockIds[line] = blockId
224
253
  }
225
254
  }
226
255
 
@@ -432,6 +461,7 @@ const analyzeEvents = (state: AnalysisState, includeDictionary: boolean): void =
432
461
  const definitionEnds = includeDictionary ? definitionMaskEnds(events) : new Map<number, number>()
433
462
  const htmlFlows: SourceRange[] = []
434
463
  let blockQuoteDepth = 0
464
+ let nextBlockId = 0
435
465
 
436
466
  for (const [phase, token] of events) {
437
467
  if (token.type === "blockQuote" && state.blockQuoteMask !== undefined) {
@@ -447,11 +477,19 @@ const analyzeEvents = (state: AnalysisState, includeDictionary: boolean): void =
447
477
  if (SENTENCE_BOUNDARY_TOKENS.has(token.type)) {
448
478
  state.sentenceBoundaryLines[token.start.line - 1] = true
449
479
  }
480
+ if (LEAF_BLOCK_TOKENS.has(token.type)) {
481
+ markLeafBlock(state, token, nextBlockId++)
482
+ }
450
483
  if (NON_PROSE_BLOCK_TOKENS.has(token.type)) {
451
484
  markNonProseBlock(state, token)
452
485
  continue
453
486
  }
454
487
  if (CONTAINER_TOKENS.has(token.type)) {
488
+ const line = token.start.line - 1
489
+ state.blockContentStarts[line] = Math.max(
490
+ state.blockContentStarts[line] ?? 0,
491
+ token.end.column - 1,
492
+ )
455
493
  markRange(state.proseMask, token.start.offset, token.end.offset)
456
494
  markRange(state.containerMask, token.start.offset, token.end.offset)
457
495
  if (includeDictionary) {
@@ -523,6 +561,7 @@ const analyzeMarkdown = (
523
561
  structuralBlanks: [],
524
562
  wordingStructuralBlanks: [],
525
563
  sentenceBoundaryLines: [],
564
+ blocks: { ids: [], contentStarts: [] },
526
565
  }
527
566
  }
528
567
 
@@ -596,6 +635,12 @@ const analyzeMarkdown = (
596
635
  (blank, lineIndex) => blank || state.blockQuoteLines?.[lineIndex] === 1,
597
636
  ),
598
637
  sentenceBoundaryLines: state.sentenceBoundaryLines,
638
+ blocks: {
639
+ ids: state.blockIds,
640
+ contentStarts: state.blockContentStarts.map(
641
+ (contentStart, index) => (starts[index] ?? 0) + contentStart,
642
+ ),
643
+ },
599
644
  }
600
645
  }
601
646
 
@@ -607,78 +652,3 @@ export function blankMarkdownForLint(
607
652
  ): MarkdownAnalysis {
608
653
  return analyzeMarkdown(inputLines, contentStarts, includeDictionary, exemptBlockQuotes)
609
654
  }
610
-
611
- export function blankMarkdownCodeWithStructure(
612
- inputLines: readonly string[],
613
- contentStarts: readonly number[] = inputLines.map(() => 0),
614
- ): MarkdownCodeResult {
615
- const analysis = analyzeMarkdown(inputLines, contentStarts, false)
616
- return {
617
- lines: analysis.lines,
618
- structuralLines: analysis.structuralLines,
619
- structuralBlanks: analysis.structuralBlanks,
620
- }
621
- }
622
-
623
- export function blankMarkdownCode(
624
- inputLines: readonly string[],
625
- contentStarts: readonly number[] = inputLines.map(() => 0),
626
- ): string[] {
627
- return analyzeMarkdown(inputLines, contentStarts, false).lines
628
- }
629
-
630
- export function maskMarkdownCode(text: string): string {
631
- return blankMarkdownCode(text.split("\n")).join("\n")
632
- }
633
-
634
- export function blankMarkdownDestinations(
635
- lines: readonly string[],
636
- contentStarts: readonly number[] = lines.map(() => 0),
637
- ): string[] {
638
- return analyzeMarkdown(lines, contentStarts).dictionaryLines
639
- }
640
-
641
- export function blankInlineCode(lines: readonly string[]): string[] {
642
- const source = lines.join("\n")
643
- const mask = new Uint8Array(source.length)
644
- const parser = source.includes(")") ? commonMarkParser : codeOnlyInlineParser
645
- const pending = [...(parser.parseInline(source, 0) as readonly InlineElement[])]
646
-
647
- while (pending.length > 0) {
648
- const element = pending.pop()
649
- if (element === undefined) continue
650
- if (element.children !== undefined) pending.push(...element.children)
651
- if (inlineElementName(element) === "InlineCode") {
652
- markRange(mask, element.from, element.to)
653
- }
654
- }
655
-
656
- let offset = 0
657
- return lines.map((line) => {
658
- const characters = line.split("")
659
- for (let index = 0; index < line.length; index++) {
660
- if (mask[offset + index] !== 0) characters[index] = " "
661
- }
662
- offset += line.length + 1
663
- return characters.join("")
664
- })
665
- }
666
-
667
- export function proseVisibility(text: string): Uint8Array {
668
- const visibility = new Uint8Array(text.length)
669
- const sourceLines = text.split("\n")
670
- const proseLines = blankMarkdownCode(sourceLines)
671
- let offset = 0
672
-
673
- for (let index = 0; index < sourceLines.length; index++) {
674
- const line = sourceLines[index] ?? ""
675
- if (line === proseLines[index]) visibility.fill(1, offset, offset + line.length)
676
- offset += line.length
677
- if (offset < text.length) {
678
- visibility[offset] = 1
679
- offset++
680
- }
681
- }
682
-
683
- return visibility
684
- }
@@ -1,3 +1,5 @@
1
+ import type { BlockStructure } from "./markdown.ts"
2
+
1
3
  export interface Paragraph {
2
4
  readonly lines: readonly string[]
3
5
  readonly boundaryLines: readonly string[]
@@ -5,140 +7,50 @@ export interface Paragraph {
5
7
  readonly column: number
6
8
  }
7
9
 
8
- const LIST_MARKER = /^(?:[-*+]|\d+[.)])\s+/
9
-
10
- type LineKind = "blank" | "block-boundary" | "blockquote" | "list-item" | "prose"
11
- type ParagraphKind = "blockquote" | "blockquote-list-item" | "list-item" | "prose"
12
-
13
- const ATX_HEADING = /^ {0,3}#{1,6}(?:[ \t]+|$)/
14
- const BLOCKQUOTE = /^ {0,3}>[ \t]?/
15
-
16
- function listItemContent(line: string): string | undefined {
17
- const trimmed = line.trimStart()
18
- const marker = LIST_MARKER.exec(trimmed)
19
- return marker ? trimmed.slice(marker[0].length) : undefined
20
- }
21
-
22
- function blockquoteContent(line: string): string | undefined {
23
- const marker = BLOCKQUOTE.exec(line)
24
- return marker ? line.slice(marker[0].length) : undefined
25
- }
26
-
27
- function trimSharedIndent(line: string, boundaryLine: string): readonly [string, string] {
28
- const indent = boundaryLine.length - boundaryLine.trimStart().length
29
- return [line.slice(indent), boundaryLine.slice(indent)]
30
- }
31
-
32
- export function isParagraphBoundaryLine(line: string): boolean {
33
- return line.trim().startsWith("|") || ATX_HEADING.test(line)
34
- }
35
-
36
- function classify(line: string): LineKind {
37
- const trimmed = line.trim()
38
- if (trimmed === "") return "blank"
39
- if (isParagraphBoundaryLine(line)) return "block-boundary"
40
- if (blockquoteContent(line) !== undefined) return "blockquote"
41
- if (listItemContent(line) !== undefined) return "list-item"
42
- return "prose"
10
+ interface OpenParagraph {
11
+ readonly blockId: number
12
+ readonly line: number
13
+ readonly column: number
14
+ readonly lines: string[]
15
+ readonly boundaryLines: string[]
43
16
  }
44
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.
45
20
  export function segmentParagraphs(
46
21
  lines: readonly string[],
47
- columns: readonly number[] = lines.map(() => 1),
48
- boundaryLines: readonly string[] = lines,
22
+ columns: readonly number[],
23
+ boundaryLines: readonly string[],
24
+ blocks: BlockStructure,
49
25
  ): Paragraph[] {
50
26
  const paragraphs: Paragraph[] = []
51
- let open: {
52
- line: number
53
- column: number
54
- lines: string[]
55
- boundaryLines: string[]
56
- kind: ParagraphKind
57
- } | null = null
27
+ let open: OpenParagraph | undefined
58
28
 
59
29
  const close = () => {
60
- if (open) {
61
- paragraphs.push({
62
- lines: open.lines,
63
- boundaryLines: open.boundaryLines,
64
- line: open.line,
65
- column: open.column,
66
- })
67
- open = null
68
- }
30
+ if (open !== undefined) paragraphs.push(open)
31
+ open = undefined
69
32
  }
70
33
 
71
34
  lines.forEach((raw, index) => {
72
- const boundaryRaw = boundaryLines[index] ?? raw
73
- switch (classify(raw)) {
74
- case "blank":
75
- case "block-boundary":
76
- close()
77
- break
78
- case "blockquote": {
79
- const content = blockquoteContent(raw) ?? ""
80
- const boundaryContent = blockquoteContent(boundaryRaw) ?? ""
81
- const contentKind = classify(content)
82
- if (contentKind === "blank" || contentKind === "block-boundary") {
83
- close()
84
- break
85
- }
86
- if (contentKind === "list-item") {
87
- close()
88
- open = {
89
- line: index + 1,
90
- column: columns[index] ?? 1,
91
- lines: [listItemContent(content) ?? ""],
92
- boundaryLines: [listItemContent(boundaryContent) ?? ""],
93
- kind: "blockquote-list-item",
94
- }
95
- break
96
- }
97
- if (open?.kind !== "blockquote" && open?.kind !== "blockquote-list-item") close()
98
- if (!open) {
99
- open = {
100
- line: index + 1,
101
- column: columns[index] ?? 1,
102
- lines: [],
103
- boundaryLines: [],
104
- kind: "blockquote",
105
- }
106
- }
107
- const [paragraphContent, paragraphBoundaryContent] =
108
- open.kind === "blockquote-list-item"
109
- ? trimSharedIndent(content, boundaryContent)
110
- : [content, boundaryContent]
111
- open.lines.push(paragraphContent)
112
- open.boundaryLines.push(paragraphBoundaryContent)
113
- break
114
- }
115
- case "list-item":
116
- close()
117
- open = {
118
- line: index + 1,
119
- column: columns[index] ?? 1,
120
- lines: [listItemContent(raw) ?? ""],
121
- boundaryLines: [listItemContent(boundaryRaw) ?? ""],
122
- kind: "list-item",
123
- }
124
- break
125
- case "prose": {
126
- if (!open) {
127
- open = {
128
- line: index + 1,
129
- column: columns[index] ?? 1,
130
- lines: [],
131
- boundaryLines: [],
132
- kind: "prose",
133
- }
134
- }
135
- const [content, boundaryContent] =
136
- open.kind === "list-item" ? trimSharedIndent(raw, boundaryRaw) : [raw, boundaryRaw]
137
- open.lines.push(content)
138
- open.boundaryLines.push(boundaryContent)
139
- break
35
+ const blockId = blocks.ids[index] ?? -1
36
+ if (blockId < 0) {
37
+ close()
38
+ return
39
+ }
40
+ if (open !== undefined && open.blockId !== blockId) close()
41
+ if (open === undefined) {
42
+ open = {
43
+ blockId,
44
+ line: index + 1,
45
+ column: columns[index] ?? 1,
46
+ lines: [],
47
+ boundaryLines: [],
140
48
  }
141
49
  }
50
+
51
+ const contentStart = blocks.contentStarts[index] ?? 0
52
+ open.lines.push(raw.slice(contentStart))
53
+ open.boundaryLines.push((boundaryLines[index] ?? raw).slice(contentStart))
142
54
  })
143
55
  close()
144
56
 
@@ -1,5 +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
4
  import type { TaggedToken, Tagger } from "../tagger.ts"
4
5
  import type { Violation } from "../types.ts"
5
6
 
@@ -19,22 +20,6 @@ export type CompiledDictionary =
19
20
  | { readonly mode: "approved-words"; readonly approvedWords: ReadonlySet<string> }
20
21
  | { readonly mode: "not-approved"; readonly forms: readonly Form[] }
21
22
 
22
- interface MarkdownContext {
23
- readonly contentStart: number
24
- readonly quoteDepth: number
25
- readonly paragraphId?: number
26
- }
27
-
28
- interface ActiveParagraph {
29
- readonly id: number
30
- readonly quoteDepth: number
31
- }
32
-
33
- const ATX_HEADING = /^ {0,3}#{1,6}(?:[\t ]+|$)/
34
- const LIST_MARKER = /^ {0,3}(?:[-+*]|\d{1,9}[.)])(?:[\t ]+|$)/
35
- const SETEXT_UNDERLINE = /^ {0,3}(?:=+|-+)[\t ]*\r?$/
36
- const THEMATIC_BREAK = /^ {0,3}(?:(?:\*[\t ]*){3,}|(?:_[\t ]*){3,}|(?:-[\t ]*){3,})\r?$/
37
-
38
23
  const tokenize = (lines: readonly string[]): readonly WordToken[] => {
39
24
  const tokenPattern = new RegExp(DICTIONARY_TOKEN_SOURCE, "gu")
40
25
  return lines.flatMap((line, lineIndex) =>
@@ -62,89 +47,10 @@ export const compileDictionary = (dictionary: DictionaryData): CompiledDictionar
62
47
  }
63
48
  : { mode: "not-approved", forms: compileForms(dictionary) }
64
49
 
65
- const markdownContext = (line: string, initialContentStart = 0): MarkdownContext => {
66
- let contentStart = Math.min(initialContentStart, line.length)
67
- let quoteDepth = 0
68
-
69
- while (contentStart < line.length) {
70
- let marker = contentStart
71
- let spaces = 0
72
- while (spaces < 4 && line[marker] === " ") {
73
- marker++
74
- spaces++
75
- }
76
- if (spaces > 3 || line[marker] !== ">") {
77
- break
78
- }
79
- contentStart = marker + 1
80
- if (line[contentStart] === " " || line[contentStart] === "\t") {
81
- contentStart++
82
- }
83
- quoteDepth++
84
- }
85
-
86
- return { contentStart, quoteDepth }
87
- }
88
-
89
- const blockContent = (line: string, context: MarkdownContext): string =>
90
- line.slice(context.contentStart)
91
-
92
- const isLeafBlock = (content: string): boolean => {
93
- const listMarker = content.match(LIST_MARKER)
94
- const nestedContent = listMarker === null ? content : content.slice(listMarker[0].length)
95
- return ATX_HEADING.test(nestedContent)
96
- }
97
-
98
- const startsNewBlock = (content: string): boolean =>
99
- ATX_HEADING.test(content) ||
100
- LIST_MARKER.test(content) ||
101
- SETEXT_UNDERLINE.test(content) ||
102
- THEMATIC_BREAK.test(content)
103
-
104
- const isParagraphBlock = (content: string): boolean =>
105
- !isLeafBlock(content) && !SETEXT_UNDERLINE.test(content) && !THEMATIC_BREAK.test(content)
106
-
107
- const isIndentedCode = (content: string): boolean => /^(?: {4}|\t)/.test(content)
108
-
109
- const markdownContexts = (
110
- lines: readonly string[],
111
- contentStarts: readonly number[],
112
- ): readonly MarkdownContext[] => {
113
- let activeParagraph: ActiveParagraph | undefined
114
- let nextParagraphId = 0
115
-
116
- return lines.map((line, lineIndex) => {
117
- const context = markdownContext(line, contentStarts[lineIndex] ?? 0)
118
- const content = blockContent(line, context)
119
- if (/^[\t ]*\r?$/.test(content)) {
120
- activeParagraph = undefined
121
- return context
122
- }
123
-
124
- if (
125
- activeParagraph !== undefined &&
126
- context.quoteDepth <= activeParagraph.quoteDepth &&
127
- !startsNewBlock(content)
128
- ) {
129
- return { ...context, paragraphId: activeParagraph.id }
130
- }
131
-
132
- if (isIndentedCode(content)) {
133
- activeParagraph = undefined
134
- return context
135
- }
136
-
137
- const paragraphId = nextParagraphId++
138
- activeParagraph = isParagraphBlock(content)
139
- ? { id: paragraphId, quoteDepth: context.quoteDepth }
140
- : undefined
141
- return { ...context, paragraphId }
142
- })
143
- }
144
-
50
+ // Two words join across a line break only inside one Markdown leaf block.
145
51
  const isSoftLineBreak = (
146
52
  lines: readonly string[],
147
- contexts: readonly MarkdownContext[],
53
+ blocks: BlockStructure,
148
54
  previous: WordToken,
149
55
  token: WordToken,
150
56
  ): boolean => {
@@ -157,26 +63,20 @@ const isSoftLineBreak = (
157
63
  return false
158
64
  }
159
65
 
160
- const previousContext = contexts[previous.lineIndex]
161
- const nextContext = contexts[token.lineIndex]
162
- if (
163
- previousContext === undefined ||
164
- nextContext === undefined ||
165
- previousContext.paragraphId === undefined ||
166
- previousContext.paragraphId !== nextContext.paragraphId
167
- ) {
66
+ const previousBlock = blocks.ids[previous.lineIndex] ?? -1
67
+ if (previousBlock < 0 || previousBlock !== blocks.ids[token.lineIndex]) {
168
68
  return false
169
69
  }
170
70
 
171
71
  const lineEnd = previousLine.endsWith("\r") ? previousLine.length - 1 : previousLine.length
172
72
  const trailing = previousLine.slice(previous.offset + previous.text.length, lineEnd)
173
- const leading = nextLine.slice(nextContext.contentStart, token.offset)
73
+ const leading = nextLine.slice(blocks.contentStarts[token.lineIndex] ?? 0, token.offset)
174
74
  return (trailing === "" || trailing === " ") && /^[\t ]*$/.test(leading)
175
75
  }
176
76
 
177
77
  const hasWords = (
178
78
  lines: readonly string[],
179
- contexts: readonly MarkdownContext[],
79
+ blocks: BlockStructure,
180
80
  tokens: readonly WordToken[],
181
81
  start: number,
182
82
  words: readonly string[],
@@ -200,7 +100,7 @@ const hasWords = (
200
100
  /^\s+$/.test(line.slice(previous.offset + previous.text.length, token.offset))
201
101
  )
202
102
  }
203
- return isSoftLineBreak(lines, contexts, previous, token)
103
+ return isSoftLineBreak(lines, blocks, previous, token)
204
104
  })
205
105
 
206
106
  const hasPartOfSpeech = (
@@ -246,8 +146,8 @@ const approvedWordRule = (
246
146
  export function dictionaryRule(
247
147
  lines: readonly string[],
248
148
  dictionary: CompiledDictionary,
149
+ blocks: BlockStructure,
249
150
  tagger?: Tagger,
250
- contentStarts: readonly number[] = lines.map(() => 0),
251
151
  proseLines: readonly string[] = lines,
252
152
  ): Violation[] {
253
153
  if (dictionary.mode === "approved-words") {
@@ -255,7 +155,6 @@ export function dictionaryRule(
255
155
  }
256
156
  const forms = dictionary.forms
257
157
  const violations: Violation[] = []
258
- const contexts = markdownContexts(lines, contentStarts)
259
158
  const tokens = tokenize(lines)
260
159
  const taggedTokensByLine = new Map<number, readonly TaggedToken[]>()
261
160
 
@@ -264,7 +163,7 @@ export function dictionaryRule(
264
163
  if (first === undefined) {
265
164
  continue
266
165
  }
267
- const candidates = forms.filter((form) => hasWords(lines, contexts, tokens, index, form.words))
166
+ const candidates = forms.filter((form) => hasWords(lines, blocks, tokens, index, form.words))
268
167
  const match = candidates.find((form) => {
269
168
  if (form.entry.partsOfSpeech === undefined) {
270
169
  return true
@@ -46,7 +46,7 @@ export function phrasalVerb(lines: readonly string[], dictionary: Dictionary): V
46
46
  message: `Do not use a phrasal verb. Use "${suggestion}", not "${match.found.toLowerCase()}".`,
47
47
  line: match.line,
48
48
  column: match.column,
49
- suggestion,
49
+ suggestions: [suggestion],
50
50
  })),
51
51
  )
52
52
  }
@@ -1,5 +1,3 @@
1
- import { isParagraphBoundaryLine } from "./paragraphs.ts"
2
-
3
1
  export interface Sentence {
4
2
  readonly text: string
5
3
  readonly line: number
@@ -156,34 +154,6 @@ function underscoreEmphasisClosers(
156
154
  return closers
157
155
  }
158
156
 
159
- function atxHeadingPrefixes(text: string): Uint8Array {
160
- const prefixes = new Uint8Array(text.length)
161
- let lineStart = 0
162
-
163
- for (let lineEnd = 0; lineEnd <= text.length; lineEnd += 1) {
164
- if (lineEnd < text.length && text[lineEnd] !== "\n") continue
165
-
166
- let markerStart = lineStart
167
- while (markerStart < lineEnd && markerStart - lineStart < 3 && text[markerStart] === " ") {
168
- markerStart += 1
169
- }
170
- let markerEnd = markerStart
171
- while (markerEnd < lineEnd && markerEnd - markerStart < 6 && text[markerEnd] === "#") {
172
- markerEnd += 1
173
- }
174
- if (
175
- markerEnd > markerStart &&
176
- text[markerEnd] !== "#" &&
177
- (markerEnd === lineEnd || /[ \t]/u.test(text[markerEnd] ?? ""))
178
- ) {
179
- prefixes.fill(1, markerStart, markerEnd)
180
- }
181
- lineStart = lineEnd + 1
182
- }
183
-
184
- return prefixes
185
- }
186
-
187
157
  function contentLookahead(
188
158
  text: string,
189
159
  brackets: Int32Array,
@@ -191,11 +161,10 @@ function contentLookahead(
191
161
  ): Int32Array {
192
162
  const attached = new Int32Array(text.length + 1).fill(-1)
193
163
  const detached = new Int32Array(text.length + 1).fill(-1)
194
- const headingPrefixes = atxHeadingPrefixes(text)
195
164
 
196
165
  for (let index = text.length - 1; index >= 0; index -= 1) {
197
166
  const character = text[index] ?? ""
198
- if (/\s/u.test(character) || headingPrefixes[index] === 1) {
167
+ if (/\s/u.test(character)) {
199
168
  attached[index] = sentinelAt(detached, index + 1)
200
169
  detached[index] = sentinelAt(detached, index + 1)
201
170
  continue
@@ -502,11 +471,8 @@ export function segmentSentences(
502
471
  const boundaryAnalysis = analyzeBoundaryText(boundaryText)
503
472
  const boundaryOffsets: number[] = []
504
473
  const paragraphEnds: number[] = []
505
- const lookaheadBreaks = effectiveBoundaryLines.map(
506
- (line, index) =>
507
- (structuralBlanks[index] ?? true) ||
508
- (sentenceBoundaryLines[index] ?? false) ||
509
- isParagraphBoundaryLine(line),
474
+ const lookaheadBreaks = lines.map(
475
+ (_line, index) => (structuralBlanks[index] ?? true) || (sentenceBoundaryLines[index] ?? false),
510
476
  )
511
477
  let boundaryOffset = 0
512
478
  for (const line of effectiveBoundaryLines) {
@@ -16,7 +16,6 @@ export interface Violation {
16
16
  readonly suggestions?: readonly string[]
17
17
  readonly line: number
18
18
  readonly column: number
19
- readonly suggestion?: string
20
19
  }
21
20
 
22
21
  export interface ReportViolation extends Violation {
@@ -16,7 +16,7 @@ import type { AutocompleteItem } from "@earendil-works/pi-tui"
16
16
  import { Effect } from "effect"
17
17
  import { Type } from "typebox"
18
18
  import { blankCommitMetadata, findCommitInvocations } from "../adapter/commit-message.ts"
19
- import { formatViolations, violationDetails } from "../adapter/feedback.ts"
19
+ import { formatViolations, splitViolations, violationDetails } from "../adapter/feedback.ts"
20
20
  import { formatStatusSummary, ruleSummary } from "../adapter/rule-summary.ts"
21
21
  import { loadConfig } from "../config/load.ts"
22
22
  import type { SteConfig } from "../config/schema.ts"
@@ -236,6 +236,24 @@ function enforceStrictEvent(state: SessionState, eventValue: unknown): void {
236
236
  }
237
237
  }
238
238
 
239
+ // Strict mode must have the last word on user-facing text, and the public hooks cannot give it.
240
+ // `emit` drops handler results for every event except `session_before_*` (pi
241
+ // dist/core/extensions/runner.js:623-652), and `message_end` and `tool_result` chain
242
+ // last-writer-wins in extension load order (pi docs/extensions.md:848-851). No `pi.on` overload
243
+ // takes a priority, so a later extension can restore prose that this extension removed.
244
+ // These patches run after the whole handler loop, which is the only remaining seam.
245
+ //
246
+ // Four assumptions hold this together, and each one fails silently.
247
+ // `test/extension/extension.test.ts` guards them:
248
+ // 1. pi still calls `emit`, `emitMessageEnd`, and `emitToolResult`. A rename leaves the patch on
249
+ // the prototype, but pi never invokes it, and redaction stops with no error.
250
+ // 2. The extension and the host share one `ExtensionRunner` class object. The pi loader aliases
251
+ // the package specifier to the module the host runs, so this prototype is the live one.
252
+ // 3. `createContext().sessionManager` keeps a stable identity, because it is the WeakMap key.
253
+ // A per-call wrapper makes every lookup miss.
254
+ // 4. `pi.on("tool_result")` stays registered. AgentSession gates the call behind
255
+ // `hasHandlers("tool_result")` (pi dist/core/agent-session.js:246), so a drop of that handler
256
+ // makes the `emitToolResult` patch dead.
239
257
  function installStrictOutputBoundary(): void {
240
258
  if (boundaryRegistry.installed) return
241
259
  boundaryRegistry.installed = true
@@ -292,7 +310,7 @@ function showReplyReport(
292
310
  report: LintReport,
293
311
  queueFeedback: boolean,
294
312
  ): void {
295
- const hard = report.violations.filter((violation) => violation.severity === "hard")
313
+ const { hard } = splitViolations(report.violations)
296
314
  const softCount = report.summary.total - report.summary.hard
297
315
  state.pendingReplyFeedback =
298
316
  queueFeedback && hard.length > 0 ? formatReplyFeedback(hard) : undefined
@@ -364,8 +382,7 @@ function lintProposedText(
364
382
  sourceDialect: classification.sourceDialect,
365
383
  previousText,
366
384
  })
367
- const hard = report.violations.filter((violation) => violation.severity === "hard")
368
- const soft = report.violations.filter((violation) => violation.severity === "soft")
385
+ const { hard, soft } = splitViolations(report.violations)
369
386
  notifyWarnings(ctx, path, soft)
370
387
  if (hard.length === 0) {
371
388
  if (soft.length > 0) {
@@ -457,7 +474,7 @@ function lintStrictReply(
457
474
  }
458
475
  const report = lintReply(state, text)
459
476
  showReplyReport(state, ctx, report, false)
460
- const hard = report.violations.filter((violation) => violation.severity === "hard")
477
+ const { hard } = splitViolations(report.violations)
461
478
  if (hard.length === 0) return undefined
462
479
  return {
463
480
  block: true,
@@ -0,0 +1,5 @@
1
+ export function isFileError(cause: unknown, code: string): boolean {
2
+ return (
3
+ typeof cause === "object" && cause !== null && (cause as NodeJS.ErrnoException).code === code
4
+ )
5
+ }