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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -2
- package/src/adapter/feedback.ts +9 -1
- package/src/cli/hook.ts +19 -101
- package/src/cli/main.ts +2 -2
- package/src/cli/observation-log.ts +1 -6
- package/src/cli/session-state.ts +4 -19
- package/src/cli/try-async.ts +7 -0
- package/src/config/load.ts +2 -4
- package/src/engine/case-fold.ts +3 -2
- package/src/engine/lint.ts +10 -2
- package/src/engine/markdown.ts +49 -79
- package/src/engine/paragraphs.ts +33 -121
- package/src/engine/rules/dictionary.ts +10 -111
- package/src/engine/rules/phrasal-verb.ts +1 -1
- package/src/engine/sentences.ts +3 -37
- package/src/engine/types.ts +0 -1
- package/src/extension/index.ts +22 -5
- package/src/fs-error.ts +5 -0
|
@@ -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.
|
|
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.
|
|
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
|
},
|
package/src/adapter/feedback.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
})
|
|
435
|
+
tryAsync("cannot update session state", () =>
|
|
436
|
+
setReplyFeedback(sessionId, replyIdentity, feedback),
|
|
437
|
+
)
|
|
507
438
|
|
|
508
439
|
const takePendingFeedback = (sessionId: string) =>
|
|
509
|
-
|
|
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
|
-
|
|
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
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
|
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
|
|
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)
|
package/src/cli/session-state.ts
CHANGED
|
@@ -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)
|
package/src/config/load.ts
CHANGED
|
@@ -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 &&
|
|
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) =>
|
package/src/engine/case-fold.ts
CHANGED
|
@@ -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
|
-
|
|
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) => ({
|
package/src/engine/lint.ts
CHANGED
|
@@ -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,
|
package/src/engine/markdown.ts
CHANGED
|
@@ -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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
readonly
|
|
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
|
-
}
|
package/src/engine/paragraphs.ts
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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[]
|
|
48
|
-
boundaryLines: readonly string[]
|
|
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
|
-
|
|
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
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
161
|
-
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
}
|
package/src/engine/sentences.ts
CHANGED
|
@@ -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)
|
|
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 =
|
|
506
|
-
(
|
|
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) {
|
package/src/engine/types.ts
CHANGED
package/src/extension/index.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
477
|
+
const { hard } = splitViolations(report.violations)
|
|
461
478
|
if (hard.length === 0) return undefined
|
|
462
479
|
return {
|
|
463
480
|
block: true,
|